pic-compressor 0.1.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/LICENSE +21 -0
- package/README.md +90 -0
- package/dist/index.cjs +543 -0
- package/dist/index.d.cts +99 -0
- package/dist/index.d.mts +99 -0
- package/dist/index.mjs +536 -0
- package/dist/index.umd.js +2 -0
- package/package.json +85 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026-present, chandq
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# pic-compressor
|
|
2
|
+
|
|
3
|
+
[](https://github.com/chandq/pic-compressor/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/pic-compressor)
|
|
5
|
+
[](vitest.config.ts)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](package.json)
|
|
8
|
+
|
|
9
|
+
独立的浏览器图片压缩工具,基于 Canvas,零运行时依赖,支持普通图片、长截图、全景图、Blob、FileList、目标体积迭代和取消操作。
|
|
10
|
+
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install pic-compressor
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## 使用
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { compressImage } from 'pic-compressor';
|
|
21
|
+
|
|
22
|
+
const result = await compressImage(file, {
|
|
23
|
+
preset: 'balanced',
|
|
24
|
+
outputMode: 'compact'
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
console.log(result.file, result.width, result.height, result.afterKB);
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
预设包括 `balanced`、`social`、`high-quality`、`thumbnail` 和 `long-image`。显式参数优先于预设。默认 `balanced` 目标为最大 1920px、约 500KB,长图会保留合理长边并受像素和 Canvas 上限约束。
|
|
31
|
+
|
|
32
|
+
指定目标体积时,压缩器会先搜索最高可接受质量,必要时再按比例缩小尺寸:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
const result = await compressImage(file, {
|
|
36
|
+
mime: 'image/webp',
|
|
37
|
+
targetFileSizeKB: 300,
|
|
38
|
+
outputMode: 'compact',
|
|
39
|
+
onProgress: (progress) => console.log(`${progress}%`)
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
输入可以是 `File`、`Blob` 或 `FileList`。Blob 可通过 `fileName` 指定输出文件名。默认保留旧版 Data URL 和二进制字段;在生产环境推荐 `outputMode: 'compact'` 降低内存峰值。
|
|
44
|
+
|
|
45
|
+
格式转换允许体积变大,例如 JPEG 转 PNG。对于同格式输出,`keepOriginalIfLarger` 默认为 `true`;若业务需要保留更大的重编码结果,可设置为 `false`。
|
|
46
|
+
|
|
47
|
+
## 浏览器 CDN
|
|
48
|
+
|
|
49
|
+
```html
|
|
50
|
+
<script src="https://unpkg.com/pic-compressor/dist/index.umd.js"></script>
|
|
51
|
+
<script>
|
|
52
|
+
const result = await PicCompressor.compressImage(file, { outputMode: 'compact' });
|
|
53
|
+
</script>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 开发
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npm install
|
|
60
|
+
npm run verify
|
|
61
|
+
npm run build
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
构建输出:
|
|
65
|
+
|
|
66
|
+
- `dist/index.mjs`:ESM
|
|
67
|
+
- `dist/index.cjs`:CommonJS
|
|
68
|
+
- `dist/index.d.mts` / `dist/index.d.cts`:TypeScript 声明
|
|
69
|
+
- `dist/index.umd.js`:压缩后的 UMD 浏览器包及 Source Map
|
|
70
|
+
|
|
71
|
+
ESM、CommonJS、UMD 和类型声明均由 tsdown 生成,Lint 和格式化由 Oxc 的 `oxlint`/`oxfmt` 完成。
|
|
72
|
+
|
|
73
|
+
## API
|
|
74
|
+
|
|
75
|
+
完整参数和返回值见 [docs/api.md](docs/api.md)。
|
|
76
|
+
|
|
77
|
+
## 社区与贡献
|
|
78
|
+
|
|
79
|
+
- 参与开发请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
80
|
+
- 社区行为规范见 [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
|
|
81
|
+
- 安全问题请按照 [SECURITY.md](SECURITY.md) 私下报告,不要提交公开 Issue
|
|
82
|
+
- 仓库开发约定及自动化工具指南见 [AGENTS.md](AGENTS.md)
|
|
83
|
+
|
|
84
|
+
## 兼容性
|
|
85
|
+
|
|
86
|
+
目标环境需要 `File`、`Blob`、`FileReader`、`Canvas 2D` 和图片解码 API。原生 iOS、Android、React Native 文件 URI 需要先转换为 Web `File`,或使用原生图片 API。
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
/*! pic-compressor v0.1.0 | MIT License | https://github.com/chandq/pic-compressor */
|
|
2
|
+
Object.defineProperties(exports, {
|
|
3
|
+
__esModule: { value: true },
|
|
4
|
+
[Symbol.toStringTag]: { value: "Module" }
|
|
5
|
+
});
|
|
6
|
+
//#region src/index.ts
|
|
7
|
+
/**
|
|
8
|
+
* Browser image compression utilities.
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
function isObject(value) {
|
|
12
|
+
return value !== null && typeof value === "object";
|
|
13
|
+
}
|
|
14
|
+
const IMAGE_TYPES = [
|
|
15
|
+
"image/jpeg",
|
|
16
|
+
"image/png",
|
|
17
|
+
"image/webp",
|
|
18
|
+
"image/avif"
|
|
19
|
+
];
|
|
20
|
+
const DEFAULT_MAX_EDGE = 2560;
|
|
21
|
+
const DEFAULT_MAX_PIXELS = 8388608;
|
|
22
|
+
const LONG_IMAGE_RATIO = 3;
|
|
23
|
+
const DEFAULT_MAX_CANVAS_DIMENSION = 8192;
|
|
24
|
+
const DEFAULT_MAX_ITERATIONS = 8;
|
|
25
|
+
/** Detect whether the current runtime can create a usable Canvas 2D context. */
|
|
26
|
+
function supportCanvas() {
|
|
27
|
+
if (typeof document === "undefined" || typeof document.createElement !== "function") return false;
|
|
28
|
+
try {
|
|
29
|
+
const canvas = document.createElement("canvas");
|
|
30
|
+
return typeof canvas.getContext === "function" && canvas.getContext("2d") !== null;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const IMAGE_COMPRESSION_PRESETS = {
|
|
36
|
+
balanced: {
|
|
37
|
+
quality: .82,
|
|
38
|
+
minQuality: .6,
|
|
39
|
+
maxWidth: 1920,
|
|
40
|
+
maxHeight: 1920,
|
|
41
|
+
maxPixels: 6291456,
|
|
42
|
+
targetFileSizeKB: 500,
|
|
43
|
+
preserveLongImage: true
|
|
44
|
+
},
|
|
45
|
+
social: {
|
|
46
|
+
quality: .82,
|
|
47
|
+
minQuality: .62,
|
|
48
|
+
maxWidth: 1280,
|
|
49
|
+
maxHeight: 1280,
|
|
50
|
+
maxPixels: 4194304,
|
|
51
|
+
targetFileSizeKB: 300,
|
|
52
|
+
preserveLongImage: true
|
|
53
|
+
},
|
|
54
|
+
"high-quality": {
|
|
55
|
+
quality: .88,
|
|
56
|
+
minQuality: .72,
|
|
57
|
+
maxWidth: 2560,
|
|
58
|
+
maxHeight: 2560,
|
|
59
|
+
maxPixels: DEFAULT_MAX_PIXELS,
|
|
60
|
+
targetFileSizeKB: 1024,
|
|
61
|
+
preserveLongImage: true
|
|
62
|
+
},
|
|
63
|
+
thumbnail: {
|
|
64
|
+
quality: .78,
|
|
65
|
+
minQuality: .58,
|
|
66
|
+
maxWidth: 400,
|
|
67
|
+
maxHeight: 400,
|
|
68
|
+
maxPixels: 16e4,
|
|
69
|
+
targetFileSizeKB: 30
|
|
70
|
+
},
|
|
71
|
+
"long-image": {
|
|
72
|
+
quality: .82,
|
|
73
|
+
minQuality: .65,
|
|
74
|
+
maxWidth: 1080,
|
|
75
|
+
maxHeight: DEFAULT_MAX_CANVAS_DIMENSION,
|
|
76
|
+
maxPixels: 12582912,
|
|
77
|
+
preserveLongImage: true
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
function isFile(value) {
|
|
81
|
+
return typeof File !== "undefined" && value instanceof File || Object.prototype.toString.call(value) === "[object File]";
|
|
82
|
+
}
|
|
83
|
+
function isFileList(value) {
|
|
84
|
+
return typeof FileList !== "undefined" && value instanceof FileList || Object.prototype.toString.call(value) === "[object FileList]";
|
|
85
|
+
}
|
|
86
|
+
function isBlob(value) {
|
|
87
|
+
return typeof Blob !== "undefined" && value instanceof Blob || Object.prototype.toString.call(value) === "[object Blob]";
|
|
88
|
+
}
|
|
89
|
+
function createAbortError() {
|
|
90
|
+
if (typeof DOMException !== "undefined") return new DOMException("Image compression aborted", "AbortError");
|
|
91
|
+
const error = /* @__PURE__ */ new Error("Image compression aborted");
|
|
92
|
+
error.name = "AbortError";
|
|
93
|
+
return error;
|
|
94
|
+
}
|
|
95
|
+
function throwIfAborted(signal) {
|
|
96
|
+
if (signal?.aborted) throw createAbortError();
|
|
97
|
+
}
|
|
98
|
+
function assertFiniteNumber(value, name, min, max = Infinity) {
|
|
99
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) throw new RangeError(`${name} must be a finite number between ${min} and ${max}`);
|
|
100
|
+
}
|
|
101
|
+
function normalizeOptions(options) {
|
|
102
|
+
const { preset = "balanced", quality: configuredQuality, mime = "image/jpeg", maxWidth, maxHeight, maxSize, minFileSizeKB = 50, maxPixels: configuredMaxPixels, maxCanvasDimension = DEFAULT_MAX_CANVAS_DIMENSION, preserveLongImage: configuredPreserveLongImage, targetFileSizeKB: configuredTargetFileSizeKB, minQuality: configuredMinQuality, maxIterations = DEFAULT_MAX_ITERATIONS, concurrency = 2, outputMode = "legacy", keepOriginalIfLarger = true, backgroundColor = "#fff", strictMime = false, onProgress, signal, fileName = "image" } = isObject(options) ? options : {};
|
|
103
|
+
const presetOptions = IMAGE_COMPRESSION_PRESETS[preset];
|
|
104
|
+
if (!presetOptions) throw new TypeError(`Unsupported image compression preset: ${String(preset)}`);
|
|
105
|
+
const quality = configuredQuality ?? presetOptions.quality;
|
|
106
|
+
const minQuality = configuredMinQuality ?? Math.min(presetOptions.minQuality, quality);
|
|
107
|
+
const maxPixels = configuredMaxPixels ?? presetOptions.maxPixels;
|
|
108
|
+
const targetFileSizeKB = configuredTargetFileSizeKB === null ? void 0 : configuredTargetFileSizeKB ?? presetOptions.targetFileSizeKB;
|
|
109
|
+
const hasExplicitDimensions = maxWidth !== void 0 || maxHeight !== void 0 || maxSize !== void 0;
|
|
110
|
+
const resolvedMaxWidth = hasExplicitDimensions ? maxWidth ?? maxSize : presetOptions.maxWidth;
|
|
111
|
+
const resolvedMaxHeight = hasExplicitDimensions ? maxHeight ?? maxSize : presetOptions.maxHeight;
|
|
112
|
+
const preserveLongImage = configuredPreserveLongImage ?? (!hasExplicitDimensions && !!presetOptions.preserveLongImage);
|
|
113
|
+
assertFiniteNumber(quality, "quality", 0, 1);
|
|
114
|
+
assertFiniteNumber(minQuality, "minQuality", 0, 1);
|
|
115
|
+
if (minQuality > quality) throw new RangeError("minQuality must not be greater than quality");
|
|
116
|
+
if (!IMAGE_TYPES.includes(mime)) throw new TypeError(`Unsupported image mime type: ${String(mime)}`);
|
|
117
|
+
if (resolvedMaxWidth !== void 0) assertFiniteNumber(resolvedMaxWidth, "maxWidth", 1);
|
|
118
|
+
if (resolvedMaxHeight !== void 0) assertFiniteNumber(resolvedMaxHeight, "maxHeight", 1);
|
|
119
|
+
if (maxSize !== void 0) assertFiniteNumber(maxSize, "maxSize", 1);
|
|
120
|
+
assertFiniteNumber(minFileSizeKB, "minFileSizeKB", 0);
|
|
121
|
+
assertFiniteNumber(maxPixels, "maxPixels", 1);
|
|
122
|
+
assertFiniteNumber(maxCanvasDimension, "maxCanvasDimension", 1);
|
|
123
|
+
if (targetFileSizeKB !== void 0) assertFiniteNumber(targetFileSizeKB, "targetFileSizeKB", 1);
|
|
124
|
+
assertFiniteNumber(maxIterations, "maxIterations", 1);
|
|
125
|
+
assertFiniteNumber(concurrency, "concurrency", 1);
|
|
126
|
+
if (typeof keepOriginalIfLarger !== "boolean") throw new TypeError("keepOriginalIfLarger must be a boolean");
|
|
127
|
+
if (typeof backgroundColor !== "string") throw new TypeError("backgroundColor must be a string");
|
|
128
|
+
if (typeof strictMime !== "boolean") throw new TypeError("strictMime must be a boolean");
|
|
129
|
+
if (typeof preserveLongImage !== "boolean") throw new TypeError("preserveLongImage must be a boolean");
|
|
130
|
+
if (onProgress !== void 0 && typeof onProgress !== "function") throw new TypeError("onProgress must be a function");
|
|
131
|
+
if (typeof fileName !== "string" || fileName.length === 0) throw new TypeError("fileName must be a non-empty string");
|
|
132
|
+
if (outputMode !== "legacy" && outputMode !== "compact") throw new TypeError(`outputMode must be "legacy" or "compact"`);
|
|
133
|
+
return {
|
|
134
|
+
preset,
|
|
135
|
+
quality,
|
|
136
|
+
mime,
|
|
137
|
+
maxWidth: resolvedMaxWidth === void 0 ? void 0 : Math.floor(resolvedMaxWidth),
|
|
138
|
+
maxHeight: resolvedMaxHeight === void 0 ? void 0 : Math.floor(resolvedMaxHeight),
|
|
139
|
+
minFileSizeKB,
|
|
140
|
+
maxPixels: Math.floor(maxPixels),
|
|
141
|
+
maxCanvasDimension: Math.floor(maxCanvasDimension),
|
|
142
|
+
preserveLongImage,
|
|
143
|
+
targetFileSizeKB,
|
|
144
|
+
minQuality,
|
|
145
|
+
maxIterations: Math.max(1, Math.floor(maxIterations)),
|
|
146
|
+
concurrency: Math.max(1, Math.floor(concurrency)),
|
|
147
|
+
outputMode,
|
|
148
|
+
keepOriginalIfLarger,
|
|
149
|
+
backgroundColor,
|
|
150
|
+
strictMime,
|
|
151
|
+
onProgress,
|
|
152
|
+
signal,
|
|
153
|
+
fileName
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function blobToFile(blob, fileName) {
|
|
157
|
+
return new File([blob], fileName, {
|
|
158
|
+
type: blob.type,
|
|
159
|
+
lastModified: Date.now()
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
function calculateTargetSize(maxWidth, maxHeight, maxPixels, maxCanvasDimension, preserveLongImage, originWidth, originHeight) {
|
|
163
|
+
let targetMaxWidth = maxWidth;
|
|
164
|
+
let targetMaxHeight = maxHeight;
|
|
165
|
+
if (targetMaxWidth === void 0 && targetMaxHeight === void 0) {
|
|
166
|
+
targetMaxWidth = DEFAULT_MAX_EDGE;
|
|
167
|
+
targetMaxHeight = DEFAULT_MAX_EDGE;
|
|
168
|
+
}
|
|
169
|
+
const aspectRatio = Math.max(originWidth / originHeight, originHeight / originWidth);
|
|
170
|
+
if (preserveLongImage && aspectRatio >= LONG_IMAGE_RATIO) {
|
|
171
|
+
if (originWidth >= originHeight) targetMaxWidth = maxCanvasDimension;
|
|
172
|
+
else targetMaxHeight = maxCanvasDimension;
|
|
173
|
+
}
|
|
174
|
+
targetMaxWidth = Math.min(targetMaxWidth ?? maxCanvasDimension, maxCanvasDimension);
|
|
175
|
+
targetMaxHeight = Math.min(targetMaxHeight ?? maxCanvasDimension, maxCanvasDimension);
|
|
176
|
+
const dimensionScale = Math.min(1, targetMaxWidth / originWidth, targetMaxHeight / originHeight);
|
|
177
|
+
const pixelScale = Math.min(1, Math.sqrt(maxPixels / (originWidth * originHeight)));
|
|
178
|
+
const scale = Math.min(dimensionScale, pixelScale);
|
|
179
|
+
return {
|
|
180
|
+
width: Math.max(1, Math.floor(originWidth * scale)),
|
|
181
|
+
height: Math.max(1, Math.floor(originHeight * scale))
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
function readBlob(blob, mode, signal) {
|
|
185
|
+
return new Promise((resolve, reject) => {
|
|
186
|
+
throwIfAborted(signal);
|
|
187
|
+
const reader = new FileReader();
|
|
188
|
+
let settled = false;
|
|
189
|
+
const cleanup = () => {
|
|
190
|
+
reader.onerror = null;
|
|
191
|
+
reader.onabort = null;
|
|
192
|
+
reader.onload = null;
|
|
193
|
+
signal?.removeEventListener("abort", abort);
|
|
194
|
+
};
|
|
195
|
+
const fail = (error) => {
|
|
196
|
+
if (settled) return;
|
|
197
|
+
settled = true;
|
|
198
|
+
cleanup();
|
|
199
|
+
reject(error);
|
|
200
|
+
};
|
|
201
|
+
const abort = () => {
|
|
202
|
+
try {
|
|
203
|
+
reader.abort();
|
|
204
|
+
} finally {
|
|
205
|
+
fail(createAbortError());
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
reader.onerror = () => fail(reader.error ?? /* @__PURE__ */ new Error("Failed to read image data"));
|
|
209
|
+
reader.onabort = () => fail(createAbortError());
|
|
210
|
+
reader.onload = () => {
|
|
211
|
+
if (settled) return;
|
|
212
|
+
const result = reader.result;
|
|
213
|
+
if (mode === "dataURL" && typeof result === "string") {
|
|
214
|
+
settled = true;
|
|
215
|
+
cleanup();
|
|
216
|
+
resolve(result);
|
|
217
|
+
} else if (mode === "arrayBuffer" && result instanceof ArrayBuffer) {
|
|
218
|
+
settled = true;
|
|
219
|
+
cleanup();
|
|
220
|
+
resolve(result);
|
|
221
|
+
} else fail(/* @__PURE__ */ new Error(`Unexpected FileReader result for ${mode}`));
|
|
222
|
+
};
|
|
223
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
224
|
+
if (mode === "dataURL") reader.readAsDataURL(blob);
|
|
225
|
+
else reader.readAsArrayBuffer(blob);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
async function decodeImage(file, signal) {
|
|
229
|
+
throwIfAborted(signal);
|
|
230
|
+
if (typeof createImageBitmap === "function") try {
|
|
231
|
+
const bitmap = await createImageBitmap(file);
|
|
232
|
+
try {
|
|
233
|
+
throwIfAborted(signal);
|
|
234
|
+
if (!bitmap.width || !bitmap.height) throw new Error("Decoded image has invalid dimensions");
|
|
235
|
+
} catch (error) {
|
|
236
|
+
bitmap.close();
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
source: bitmap,
|
|
241
|
+
width: bitmap.width,
|
|
242
|
+
height: bitmap.height,
|
|
243
|
+
release: () => bitmap.close()
|
|
244
|
+
};
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (error.name === "AbortError") throw error;
|
|
247
|
+
}
|
|
248
|
+
return new Promise((resolve, reject) => {
|
|
249
|
+
const image = new Image();
|
|
250
|
+
let objectURL;
|
|
251
|
+
let settled = false;
|
|
252
|
+
const cleanup = () => {
|
|
253
|
+
image.onload = null;
|
|
254
|
+
image.onerror = null;
|
|
255
|
+
signal?.removeEventListener("abort", abort);
|
|
256
|
+
if (objectURL && typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(objectURL);
|
|
257
|
+
};
|
|
258
|
+
const fail = (error) => {
|
|
259
|
+
if (settled) return;
|
|
260
|
+
settled = true;
|
|
261
|
+
cleanup();
|
|
262
|
+
reject(error);
|
|
263
|
+
};
|
|
264
|
+
const abort = () => fail(createAbortError());
|
|
265
|
+
image.onload = () => {
|
|
266
|
+
if (settled) return;
|
|
267
|
+
settled = true;
|
|
268
|
+
const width = image.naturalWidth || image.width;
|
|
269
|
+
const height = image.naturalHeight || image.height;
|
|
270
|
+
cleanup();
|
|
271
|
+
if (!width || !height) reject(/* @__PURE__ */ new Error("Decoded image has invalid dimensions"));
|
|
272
|
+
else resolve({
|
|
273
|
+
source: image,
|
|
274
|
+
width,
|
|
275
|
+
height,
|
|
276
|
+
release: () => void 0
|
|
277
|
+
});
|
|
278
|
+
};
|
|
279
|
+
image.onerror = () => fail(/* @__PURE__ */ new Error(`Failed to decode image: ${file.name}`));
|
|
280
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
281
|
+
try {
|
|
282
|
+
if (typeof URL !== "undefined" && typeof URL.createObjectURL === "function") {
|
|
283
|
+
objectURL = URL.createObjectURL(file);
|
|
284
|
+
image.src = objectURL;
|
|
285
|
+
} else readBlob(file, "dataURL", signal).then((src) => image.src = src, fail);
|
|
286
|
+
} catch (error) {
|
|
287
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
function canvasToBlob(canvas, mime, quality) {
|
|
292
|
+
if (typeof canvas.toBlob === "function") return new Promise((resolve, reject) => {
|
|
293
|
+
canvas.toBlob((blob) => blob ? resolve(blob) : reject(/* @__PURE__ */ new Error("Canvas image encoding failed")), mime, quality);
|
|
294
|
+
});
|
|
295
|
+
try {
|
|
296
|
+
const [header, encoded = ""] = canvas.toDataURL(mime, quality).split(",");
|
|
297
|
+
const actualMime = /^data:([^;]+)/.exec(header ?? "")?.[1] || mime;
|
|
298
|
+
const binary = atob(encoded);
|
|
299
|
+
const bytes = new Uint8Array(binary.length);
|
|
300
|
+
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
|
|
301
|
+
return Promise.resolve(new Blob([bytes], { type: actualMime }));
|
|
302
|
+
} catch (error) {
|
|
303
|
+
return Promise.reject(error);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function normalizeMime(mime) {
|
|
307
|
+
if (mime === "image/jpg") return "image/jpeg";
|
|
308
|
+
return IMAGE_TYPES.includes(mime) ? mime : void 0;
|
|
309
|
+
}
|
|
310
|
+
function replaceFileExtension(name, mime) {
|
|
311
|
+
const extension = {
|
|
312
|
+
"image/jpeg": "jpg",
|
|
313
|
+
"image/png": "png",
|
|
314
|
+
"image/webp": "webp",
|
|
315
|
+
"image/avif": "avif"
|
|
316
|
+
};
|
|
317
|
+
const dotIndex = name.lastIndexOf(".");
|
|
318
|
+
return `${dotIndex > 0 ? name.slice(0, dotIndex) : name || "image"}.${extension[mime]}`;
|
|
319
|
+
}
|
|
320
|
+
function reportProgress(options, progress) {
|
|
321
|
+
if (!options.onProgress) return;
|
|
322
|
+
try {
|
|
323
|
+
options.onProgress(Math.max(0, Math.min(100, Math.round(progress))));
|
|
324
|
+
} catch {}
|
|
325
|
+
}
|
|
326
|
+
function requiresCompression(file, options) {
|
|
327
|
+
const sizeKB = file.size / 1024;
|
|
328
|
+
return sizeKB >= options.minFileSizeKB || options.targetFileSizeKB !== void 0 && sizeKB > options.targetFileSizeKB;
|
|
329
|
+
}
|
|
330
|
+
function supportsLossyQuality(mime) {
|
|
331
|
+
return mime === "image/jpeg" || mime === "image/webp" || mime === "image/avif";
|
|
332
|
+
}
|
|
333
|
+
function getEncodedMime(blob, requestedMime, strictMime) {
|
|
334
|
+
const actualMime = normalizeMime(blob.type);
|
|
335
|
+
if (!actualMime) throw new Error(`Canvas returned unsupported image mime type: ${blob.type || "unknown"}`);
|
|
336
|
+
if (strictMime && actualMime !== requestedMime) throw new Error(`Current runtime does not support encoding ${requestedMime}; received ${actualMime} instead`);
|
|
337
|
+
return actualMime;
|
|
338
|
+
}
|
|
339
|
+
async function encodeCanvas(canvas, options, targetBytes, remainingIterations, completedIterations) {
|
|
340
|
+
let iterations = 0;
|
|
341
|
+
const encode = async (quality) => {
|
|
342
|
+
throwIfAborted(options.signal);
|
|
343
|
+
const blob = await canvasToBlob(canvas, options.mime, quality);
|
|
344
|
+
iterations++;
|
|
345
|
+
reportProgress(options, 10 + (completedIterations + iterations) / options.maxIterations * 80);
|
|
346
|
+
return {
|
|
347
|
+
blob,
|
|
348
|
+
mime: getEncodedMime(blob, options.mime, options.strictMime),
|
|
349
|
+
quality
|
|
350
|
+
};
|
|
351
|
+
};
|
|
352
|
+
const result = await encode(options.quality);
|
|
353
|
+
if (targetBytes === void 0 || result.blob.size <= targetBytes || !supportsLossyQuality(result.mime) || remainingIterations <= 1 || options.quality <= options.minQuality) return {
|
|
354
|
+
...result,
|
|
355
|
+
iterations
|
|
356
|
+
};
|
|
357
|
+
const minimumQualityResult = await encode(options.minQuality);
|
|
358
|
+
if (minimumQualityResult.blob.size > targetBytes) return {
|
|
359
|
+
...minimumQualityResult,
|
|
360
|
+
iterations
|
|
361
|
+
};
|
|
362
|
+
let bestResult = minimumQualityResult;
|
|
363
|
+
let lowerQuality = options.minQuality;
|
|
364
|
+
let upperQuality = options.quality;
|
|
365
|
+
const searchIterations = remainingIterations - iterations;
|
|
366
|
+
for (let index = 0; index < searchIterations && upperQuality - lowerQuality > .01; index++) {
|
|
367
|
+
const nextQuality = (lowerQuality + upperQuality) / 2;
|
|
368
|
+
const nextResult = await encode(nextQuality);
|
|
369
|
+
if (nextResult.blob.size <= targetBytes) {
|
|
370
|
+
bestResult = nextResult;
|
|
371
|
+
lowerQuality = nextQuality;
|
|
372
|
+
} else upperQuality = nextQuality;
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
...bestResult,
|
|
376
|
+
iterations
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
async function encodeImage(decoded, initialWidth, initialHeight, options, targetBytes) {
|
|
380
|
+
let width = initialWidth;
|
|
381
|
+
let height = initialHeight;
|
|
382
|
+
let totalIterations = 0;
|
|
383
|
+
let lastResult;
|
|
384
|
+
while (totalIterations < options.maxIterations) {
|
|
385
|
+
const canvas = document.createElement("canvas");
|
|
386
|
+
canvas.width = width;
|
|
387
|
+
canvas.height = height;
|
|
388
|
+
const context = canvas.getContext("2d");
|
|
389
|
+
if (!context) throw new Error("Failed to create Canvas 2D context");
|
|
390
|
+
context.imageSmoothingEnabled = true;
|
|
391
|
+
context.imageSmoothingQuality = "high";
|
|
392
|
+
if (options.mime === "image/jpeg") {
|
|
393
|
+
context.fillStyle = options.backgroundColor;
|
|
394
|
+
context.fillRect(0, 0, width, height);
|
|
395
|
+
}
|
|
396
|
+
context.drawImage(decoded.source, 0, 0, width, height);
|
|
397
|
+
throwIfAborted(options.signal);
|
|
398
|
+
try {
|
|
399
|
+
lastResult = await encodeCanvas(canvas, options, targetBytes, options.maxIterations - totalIterations, totalIterations);
|
|
400
|
+
totalIterations += lastResult.iterations;
|
|
401
|
+
} finally {
|
|
402
|
+
canvas.width = 1;
|
|
403
|
+
canvas.height = 1;
|
|
404
|
+
}
|
|
405
|
+
if (targetBytes === void 0 || lastResult.blob.size <= targetBytes || totalIterations >= options.maxIterations) return {
|
|
406
|
+
...lastResult,
|
|
407
|
+
width,
|
|
408
|
+
height,
|
|
409
|
+
iterations: totalIterations
|
|
410
|
+
};
|
|
411
|
+
const targetScale = Math.min(.9, Math.sqrt(targetBytes / lastResult.blob.size) * .95);
|
|
412
|
+
const nextWidth = Math.max(1, Math.floor(width * targetScale));
|
|
413
|
+
const nextHeight = Math.max(1, Math.floor(height * targetScale));
|
|
414
|
+
if (nextWidth === width && nextHeight === height || width === 1 && height === 1) return {
|
|
415
|
+
...lastResult,
|
|
416
|
+
width,
|
|
417
|
+
height,
|
|
418
|
+
iterations: totalIterations
|
|
419
|
+
};
|
|
420
|
+
width = nextWidth;
|
|
421
|
+
height = nextHeight;
|
|
422
|
+
}
|
|
423
|
+
if (!lastResult) throw new Error("Image encoding did not produce a result");
|
|
424
|
+
return {
|
|
425
|
+
...lastResult,
|
|
426
|
+
width,
|
|
427
|
+
height,
|
|
428
|
+
iterations: totalIterations
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
async function compressOne(file, options, canvasSupported) {
|
|
432
|
+
reportProgress(options, 0);
|
|
433
|
+
throwIfAborted(options.signal);
|
|
434
|
+
if (file.type && !file.type.startsWith("image/")) throw new TypeError(`${file.name} is not an image file`);
|
|
435
|
+
const beforeKB = file.size / 1024;
|
|
436
|
+
if (!requiresCompression(file, options)) {
|
|
437
|
+
reportProgress(options, 100);
|
|
438
|
+
return { file };
|
|
439
|
+
}
|
|
440
|
+
if (!canvasSupported) throw new Error("Current runtime environment not support Canvas");
|
|
441
|
+
const decoded = await decodeImage(file, options.signal);
|
|
442
|
+
try {
|
|
443
|
+
reportProgress(options, 8);
|
|
444
|
+
throwIfAborted(options.signal);
|
|
445
|
+
const targetSize = calculateTargetSize(options.maxWidth, options.maxHeight, options.maxPixels, options.maxCanvasDimension, options.preserveLongImage, decoded.width, decoded.height);
|
|
446
|
+
const configuredTargetBytes = options.targetFileSizeKB === void 0 ? void 0 : options.targetFileSizeKB * 1024;
|
|
447
|
+
const sourceMime = normalizeMime(file.type);
|
|
448
|
+
const dimensionsInitiallyUnchanged = targetSize.width === decoded.width && targetSize.height === decoded.height;
|
|
449
|
+
const originalSizeTargetBytes = options.keepOriginalIfLarger && dimensionsInitiallyUnchanged && sourceMime === options.mime ? Math.max(0, file.size - 1) : void 0;
|
|
450
|
+
const encodingTargetBytes = configuredTargetBytes === void 0 ? originalSizeTargetBytes : originalSizeTargetBytes === void 0 ? configuredTargetBytes : Math.min(configuredTargetBytes, originalSizeTargetBytes);
|
|
451
|
+
const encoded = await encodeImage(decoded, targetSize.width, targetSize.height, options, encodingTargetBytes);
|
|
452
|
+
throwIfAborted(options.signal);
|
|
453
|
+
const { blob, mime: actualMime, width, height, quality, iterations } = encoded;
|
|
454
|
+
if (options.keepOriginalIfLarger && dimensionsInitiallyUnchanged && sourceMime === actualMime && blob.size >= file.size) {
|
|
455
|
+
reportProgress(options, 100);
|
|
456
|
+
return { file };
|
|
457
|
+
}
|
|
458
|
+
const outputFile = new File([blob], replaceFileExtension(file.name, actualMime), {
|
|
459
|
+
type: actualMime,
|
|
460
|
+
lastModified: file.lastModified
|
|
461
|
+
});
|
|
462
|
+
const result = {
|
|
463
|
+
file: outputFile,
|
|
464
|
+
beforeKB: Number(beforeKB.toFixed(2)),
|
|
465
|
+
afterKB: Number((outputFile.size / 1024).toFixed(2)),
|
|
466
|
+
width,
|
|
467
|
+
height,
|
|
468
|
+
mime: actualMime,
|
|
469
|
+
compressed: true,
|
|
470
|
+
quality,
|
|
471
|
+
iterations,
|
|
472
|
+
...options.targetFileSizeKB === void 0 ? {} : { targetAchieved: blob.size <= options.targetFileSizeKB * 1024 }
|
|
473
|
+
};
|
|
474
|
+
if (options.outputMode === "legacy") {
|
|
475
|
+
const [beforeSrc, afterSrc, arrayBuffer] = await Promise.all([
|
|
476
|
+
readBlob(file, "dataURL", options.signal),
|
|
477
|
+
readBlob(blob, "dataURL", options.signal),
|
|
478
|
+
readBlob(blob, "arrayBuffer", options.signal)
|
|
479
|
+
]);
|
|
480
|
+
throwIfAborted(options.signal);
|
|
481
|
+
result.origin = file;
|
|
482
|
+
result.beforeSrc = beforeSrc;
|
|
483
|
+
result.afterSrc = afterSrc;
|
|
484
|
+
result.bufferArray = new Uint8Array(arrayBuffer);
|
|
485
|
+
}
|
|
486
|
+
reportProgress(options, 100);
|
|
487
|
+
return result;
|
|
488
|
+
} finally {
|
|
489
|
+
decoded.release();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
async function mapWithConcurrency(items, concurrency, worker) {
|
|
493
|
+
const results = [];
|
|
494
|
+
let cursor = 0;
|
|
495
|
+
let failed = false;
|
|
496
|
+
const execute = async () => {
|
|
497
|
+
while (!failed && cursor < items.length) {
|
|
498
|
+
const index = cursor++;
|
|
499
|
+
try {
|
|
500
|
+
results[index] = await worker(items[index], index);
|
|
501
|
+
} catch (error) {
|
|
502
|
+
failed = true;
|
|
503
|
+
throw error;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, execute));
|
|
508
|
+
return results;
|
|
509
|
+
}
|
|
510
|
+
function compressImage(file, options = {}) {
|
|
511
|
+
const singleFile = isFile(file);
|
|
512
|
+
const singleBlob = !singleFile && isBlob(file);
|
|
513
|
+
const multipleFiles = isFileList(file);
|
|
514
|
+
if (!singleFile && !singleBlob && !multipleFiles) throw new TypeError(`${String(file)} require be File or FileList`);
|
|
515
|
+
const normalizedOptions = normalizeOptions(options);
|
|
516
|
+
throwIfAborted(normalizedOptions.signal);
|
|
517
|
+
if (singleFile || singleBlob) {
|
|
518
|
+
const normalizedFile = singleFile ? file : blobToFile(file, normalizedOptions.fileName);
|
|
519
|
+
return compressOne(normalizedFile, normalizedOptions, !requiresCompression(normalizedFile, normalizedOptions) || supportCanvas());
|
|
520
|
+
}
|
|
521
|
+
const files = Array.from(file);
|
|
522
|
+
const canvasSupported = !files.some((item) => requiresCompression(item, normalizedOptions)) || supportCanvas();
|
|
523
|
+
const progresses = Array.from({ length: files.length }, () => 0);
|
|
524
|
+
return mapWithConcurrency(files, normalizedOptions.concurrency, (item, index) => {
|
|
525
|
+
return compressOne(item, normalizedOptions.onProgress ? {
|
|
526
|
+
...normalizedOptions,
|
|
527
|
+
onProgress: (progress) => {
|
|
528
|
+
progresses[index] = progress;
|
|
529
|
+
reportProgress(normalizedOptions, progresses.reduce((sum, current) => sum + current, 0) / files.length);
|
|
530
|
+
}
|
|
531
|
+
} : normalizedOptions, canvasSupported);
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
var src_default = {
|
|
535
|
+
IMAGE_COMPRESSION_PRESETS,
|
|
536
|
+
supportCanvas,
|
|
537
|
+
compressImage
|
|
538
|
+
};
|
|
539
|
+
//#endregion
|
|
540
|
+
exports.IMAGE_COMPRESSION_PRESETS = IMAGE_COMPRESSION_PRESETS;
|
|
541
|
+
exports.compressImage = compressImage;
|
|
542
|
+
exports.default = src_default;
|
|
543
|
+
exports.supportCanvas = supportCanvas;
|