vue3-image-compressor 1.0.4
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 +174 -0
- package/dist/assets/imageWorker-DyeUTFOy.js +67 -0
- package/dist/components/ImageCompressor.vue.d.ts +38 -0
- package/dist/composables/useCompression.d.ts +170 -0
- package/dist/composables/useEncoderRegistry.d.ts +17 -0
- package/dist/composables/useWorker.d.ts +11 -0
- package/dist/constants/encoders.d.ts +5 -0
- package/dist/constants/resizeMethods.d.ts +39 -0
- package/dist/index.d.ts +15 -0
- package/dist/style.css +1 -0
- package/dist/types/compression.d.ts +35 -0
- package/dist/types/encoder.d.ts +100 -0
- package/dist/types/processor.d.ts +30 -0
- package/dist/types/worker.d.ts +21 -0
- package/dist/utils/file.d.ts +23 -0
- package/dist/utils/image.d.ts +31 -0
- package/dist/vue-image-compressor.js +660 -0
- package/dist/vue-image-compressor.umd.cjs +1 -0
- package/dist/workers/imageWorker.d.ts +4 -0
- package/dist/workers/utils/emscripten.d.ts +6 -0
- package/package.json +49 -0
- package/src/components/ImageCompressor.vue +304 -0
- package/src/composables/useCompression.ts +314 -0
- package/src/composables/useEncoderRegistry.ts +70 -0
- package/src/composables/useWorker.ts +132 -0
- package/src/constants/encoders.ts +137 -0
- package/src/constants/resizeMethods.ts +23 -0
- package/src/index.ts +63 -0
- package/src/types/compression.ts +38 -0
- package/src/types/encoder.ts +144 -0
- package/src/types/processor.ts +36 -0
- package/src/types/worker.ts +29 -0
- package/src/utils/file.ts +48 -0
- package/src/utils/image.ts +90 -0
- package/src/workers/imageWorker.ts +107 -0
- package/src/workers/utils/emscripten.ts +16 -0
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vue3-image-compressor",
|
|
3
|
+
"version": "1.0.4",
|
|
4
|
+
"description": "Vue3 + TypeScript 图片压缩组件,基于 Squoosh 核心原理",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/vue-image-compressor.umd.cjs",
|
|
7
|
+
"module": "./dist/vue-image-compressor.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/vue-image-compressor.js",
|
|
12
|
+
"require": "./dist/vue-image-compressor.umd.cjs",
|
|
13
|
+
"types": "./dist/index.d.ts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"src"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "vue-tsc && vite build",
|
|
22
|
+
"dev": "vite",
|
|
23
|
+
"preview": "vite preview"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"vue3",
|
|
27
|
+
"typescript",
|
|
28
|
+
"image",
|
|
29
|
+
"compression",
|
|
30
|
+
"squoosh",
|
|
31
|
+
"webp",
|
|
32
|
+
"avif",
|
|
33
|
+
"jpeg",
|
|
34
|
+
"png"
|
|
35
|
+
],
|
|
36
|
+
"author": "",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"vue": "^3.3.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@vitejs/plugin-vue": "^4.5.0",
|
|
43
|
+
"typescript": "^5.2.0",
|
|
44
|
+
"vite": "^5.0.0",
|
|
45
|
+
"vite-plugin-dts": "^3.6.0",
|
|
46
|
+
"vue": "^3.3.0",
|
|
47
|
+
"vue-tsc": "^2.2.12"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="image-compressor">
|
|
3
|
+
<!-- 文件上传区域 -->
|
|
4
|
+
<div class="upload-zone" @drop.prevent="onDrop" @dragover.prevent>
|
|
5
|
+
<input
|
|
6
|
+
type="file"
|
|
7
|
+
accept="image/*"
|
|
8
|
+
@change="onFileChange"
|
|
9
|
+
ref="fileInput"
|
|
10
|
+
class="file-input"
|
|
11
|
+
/>
|
|
12
|
+
<div class="upload-prompt" @click="fileInput?.click()">
|
|
13
|
+
<slot name="upload-prompt">
|
|
14
|
+
<p>拖拽图片到此处,或点击上传</p>
|
|
15
|
+
</slot>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<!-- 预览区域 -->
|
|
20
|
+
<div v-if="originalUrl" class="preview-area">
|
|
21
|
+
<div class="preview-item">
|
|
22
|
+
<h4>原始图片</h4>
|
|
23
|
+
<img :src="originalUrl" alt="Original" />
|
|
24
|
+
<p>{{ originalSize }}</p>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="preview-item">
|
|
27
|
+
<h4>压缩后</h4>
|
|
28
|
+
<img v-if="compressedUrl" :src="compressedUrl" alt="Compressed" />
|
|
29
|
+
<div v-else-if="isCompressing" class="compressing">
|
|
30
|
+
<progress :value="progress" max="100" />
|
|
31
|
+
<p>压缩中... {{ progress }}%</p>
|
|
32
|
+
</div>
|
|
33
|
+
<p v-if="compressedSize">{{ compressedSize }}</p>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<!-- 编码器设置 -->
|
|
38
|
+
<div class="encoder-settings" v-if="originalUrl">
|
|
39
|
+
<label>编码器:</label>
|
|
40
|
+
<select v-model="selectedEncoder" @change="onEncoderChange">
|
|
41
|
+
<option
|
|
42
|
+
v-for="enc in availableEncoders"
|
|
43
|
+
:key="enc.type"
|
|
44
|
+
:value="enc.type"
|
|
45
|
+
>
|
|
46
|
+
{{ enc.label }} ({{ enc.extension }})
|
|
47
|
+
</option>
|
|
48
|
+
</select>
|
|
49
|
+
|
|
50
|
+
<!-- 通用质量滑块 -->
|
|
51
|
+
<div class="quality-control" v-if="hasQualityOption">
|
|
52
|
+
<label>
|
|
53
|
+
质量: {{ encoderOptions.quality ?? 75 }}
|
|
54
|
+
<input
|
|
55
|
+
type="range"
|
|
56
|
+
min="0"
|
|
57
|
+
max="100"
|
|
58
|
+
:value="encoderOptions.quality ?? 75"
|
|
59
|
+
@input="updateOption('quality', Number(($event.target as HTMLInputElement).value))"
|
|
60
|
+
------- REPLACE
|
|
61
|
+
|
|
62
|
+
/>
|
|
63
|
+
</label>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<!-- 更多设置插槽 -->
|
|
67
|
+
<slot name="encoder-settings" :encoder="selectedEncoder" :options="encoderOptions" />
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<!-- 操作按钮 -->
|
|
71
|
+
<div class="actions" v-if="originalUrl">
|
|
72
|
+
<button
|
|
73
|
+
@click="startCompress"
|
|
74
|
+
:disabled="isCompressing"
|
|
75
|
+
class="btn-primary"
|
|
76
|
+
>
|
|
77
|
+
{{ isCompressing ? '压缩中...' : '开始压缩' }}
|
|
78
|
+
</button>
|
|
79
|
+
<button
|
|
80
|
+
v-if="canCancel"
|
|
81
|
+
@click="cancel"
|
|
82
|
+
class="btn-secondary"
|
|
83
|
+
>
|
|
84
|
+
取消
|
|
85
|
+
</button>
|
|
86
|
+
<button
|
|
87
|
+
v-if="result"
|
|
88
|
+
@click="downloadResult"
|
|
89
|
+
class="btn-success"
|
|
90
|
+
>
|
|
91
|
+
下载 (节省 {{ result.savingsPercent }}%)
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<!-- 错误提示 -->
|
|
96
|
+
<div v-if="error" class="error">{{ error }}</div>
|
|
97
|
+
</div>
|
|
98
|
+
</template>
|
|
99
|
+
|
|
100
|
+
<script setup lang="ts">
|
|
101
|
+
import { ref, computed, watch } from 'vue';
|
|
102
|
+
import { useCompression } from '../composables/useCompression';
|
|
103
|
+
import { useEncoderRegistry } from '../composables/useEncoderRegistry';
|
|
104
|
+
import type { CompressionOptions } from '../types/compression';
|
|
105
|
+
import { formatBytes } from '../utils/file';
|
|
106
|
+
|
|
107
|
+
const props = defineProps<{
|
|
108
|
+
// 可传入自定义编码选项
|
|
109
|
+
defaultEncoder?: string;
|
|
110
|
+
defaultOptions?: Record<string, any>;
|
|
111
|
+
}>();
|
|
112
|
+
|
|
113
|
+
const emit = defineEmits<{
|
|
114
|
+
(e: 'success', result: any): void;
|
|
115
|
+
(e: 'error', err: Error): void;
|
|
116
|
+
(e: 'cancel'): void;
|
|
117
|
+
}>();
|
|
118
|
+
|
|
119
|
+
const fileInput = ref<HTMLInputElement | null>(null);
|
|
120
|
+
const currentFile = ref<File | null>(null);
|
|
121
|
+
const originalUrl = ref<string>('');
|
|
122
|
+
const compressedUrl = ref<string>('');
|
|
123
|
+
|
|
124
|
+
const {
|
|
125
|
+
compress,
|
|
126
|
+
cancel,
|
|
127
|
+
downloadResult,
|
|
128
|
+
isCompressing,
|
|
129
|
+
progress,
|
|
130
|
+
error,
|
|
131
|
+
result,
|
|
132
|
+
canCancel,
|
|
133
|
+
} = useCompression();
|
|
134
|
+
|
|
135
|
+
const {
|
|
136
|
+
selectedEncoder,
|
|
137
|
+
encoderOptions,
|
|
138
|
+
availableEncoders,
|
|
139
|
+
selectEncoder,
|
|
140
|
+
updateOption,
|
|
141
|
+
} = useEncoderRegistry();
|
|
142
|
+
|
|
143
|
+
// 初始化默认编码器
|
|
144
|
+
if (props.defaultEncoder) {
|
|
145
|
+
selectEncoder(props.defaultEncoder as any);
|
|
146
|
+
}
|
|
147
|
+
if (props.defaultOptions) {
|
|
148
|
+
Object.entries(props.defaultOptions).forEach(([k, v]) => updateOption(k, v));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const originalSize = computed(() =>
|
|
152
|
+
currentFile.value ? formatBytes(currentFile.value.size) : ''
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const compressedSize = computed(() =>
|
|
156
|
+
result.value ? formatBytes(result.value.compressed.size) : ''
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const hasQualityOption = computed(() =>
|
|
160
|
+
['mozJPEG', 'webP', 'jxl', 'browserJPEG', 'wp2'].includes(selectedEncoder.value)
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
function onFileChange(event: Event) {
|
|
164
|
+
const input = event.target as HTMLInputElement;
|
|
165
|
+
if (input.files && input.files[0]) {
|
|
166
|
+
handleFile(input.files[0]);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function onDrop(event: DragEvent) {
|
|
171
|
+
if (event.dataTransfer?.files[0]) {
|
|
172
|
+
handleFile(event.dataTransfer.files[0]);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function handleFile(file: File) {
|
|
177
|
+
if (!file.type.startsWith('image/')) return;
|
|
178
|
+
currentFile.value = file;
|
|
179
|
+
originalUrl.value = URL.createObjectURL(file);
|
|
180
|
+
// 清理旧的压缩结果
|
|
181
|
+
if (compressedUrl.value) {
|
|
182
|
+
URL.revokeObjectURL(compressedUrl.value);
|
|
183
|
+
compressedUrl.value = '';
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function onEncoderChange() {
|
|
188
|
+
// 切换编码器时重置选项
|
|
189
|
+
selectEncoder(selectedEncoder.value);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function startCompress() {
|
|
193
|
+
if (!currentFile.value) return;
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const options: CompressionOptions = {
|
|
197
|
+
encoder: selectedEncoder.value,
|
|
198
|
+
encoderOptions: { ...encoderOptions.value },
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const compressionResult = await compress(currentFile.value, options);
|
|
202
|
+
compressedUrl.value = compressionResult.compressed.blobUrl;
|
|
203
|
+
emit('success', compressionResult);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
206
|
+
emit('cancel');
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 清理
|
|
214
|
+
watch(
|
|
215
|
+
() => currentFile.value,
|
|
216
|
+
(oldVal, newVal) => {
|
|
217
|
+
if (oldVal && oldVal !== newVal && originalUrl.value) {
|
|
218
|
+
URL.revokeObjectURL(originalUrl.value);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
);
|
|
222
|
+
</script>
|
|
223
|
+
|
|
224
|
+
<style scoped>
|
|
225
|
+
.image-compressor {
|
|
226
|
+
max-width: 800px;
|
|
227
|
+
margin: 0 auto;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.upload-zone {
|
|
231
|
+
border: 2px dashed #ccc;
|
|
232
|
+
border-radius: 8px;
|
|
233
|
+
padding: 40px;
|
|
234
|
+
text-align: center;
|
|
235
|
+
cursor: pointer;
|
|
236
|
+
transition: border-color 0.2s;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.upload-zone:hover {
|
|
240
|
+
border-color: #409eff;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.file-input {
|
|
244
|
+
display: none;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.preview-area {
|
|
248
|
+
display: grid;
|
|
249
|
+
grid-template-columns: 1fr 1fr;
|
|
250
|
+
gap: 20px;
|
|
251
|
+
margin: 20px 0;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.preview-item img {
|
|
255
|
+
max-width: 100%;
|
|
256
|
+
border-radius: 4px;
|
|
257
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.compressing {
|
|
261
|
+
padding: 40px;
|
|
262
|
+
text-align: center;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.actions {
|
|
266
|
+
display: flex;
|
|
267
|
+
gap: 10px;
|
|
268
|
+
margin-top: 20px;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.btn-primary,
|
|
272
|
+
.btn-secondary,
|
|
273
|
+
.btn-success {
|
|
274
|
+
padding: 10px 24px;
|
|
275
|
+
border: none;
|
|
276
|
+
border-radius: 4px;
|
|
277
|
+
cursor: pointer;
|
|
278
|
+
font-size: 14px;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.btn-primary {
|
|
282
|
+
background: #409eff;
|
|
283
|
+
color: white;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.btn-primary:disabled {
|
|
287
|
+
background: #a0cfff;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.btn-secondary {
|
|
291
|
+
background: #f56c6c;
|
|
292
|
+
color: white;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.btn-success {
|
|
296
|
+
background: #67c23a;
|
|
297
|
+
color: white;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.error {
|
|
301
|
+
color: #f56c6c;
|
|
302
|
+
margin-top: 10px;
|
|
303
|
+
}
|
|
304
|
+
</style>
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 核心压缩流程编排 Composable
|
|
3
|
+
* 对标 Squoosh: src/client/lazy-app/Compress/index.tsx
|
|
4
|
+
* 编排:解码 → 预处理 → 处理 → 编码 → 结果
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { ref, computed } from 'vue';
|
|
8
|
+
import type {
|
|
9
|
+
CompressionOptions,
|
|
10
|
+
CompressionResult,
|
|
11
|
+
ImageInfo,
|
|
12
|
+
} from '../types/compression';
|
|
13
|
+
import { ENCODER_REGISTRY } from '../constants/encoders';
|
|
14
|
+
import {
|
|
15
|
+
blobToImageData,
|
|
16
|
+
imageDataToBlob,
|
|
17
|
+
arrayBufferToFile,
|
|
18
|
+
sniffMimeType,
|
|
19
|
+
canDecodeImageType,
|
|
20
|
+
} from '../utils/image';
|
|
21
|
+
import {
|
|
22
|
+
calculateSavings,
|
|
23
|
+
generateCompressedFilename,
|
|
24
|
+
createBlobUrl,
|
|
25
|
+
revokeBlobUrl,
|
|
26
|
+
} from '../utils/file';
|
|
27
|
+
import { useWorker } from './useWorker';
|
|
28
|
+
|
|
29
|
+
export function useCompression() {
|
|
30
|
+
const isCompressing = ref(false);
|
|
31
|
+
const progress = ref(0);
|
|
32
|
+
const error = ref<string | null>(null);
|
|
33
|
+
const result = ref<CompressionResult | null>(null);
|
|
34
|
+
const abortController = ref<AbortController | null>(null);
|
|
35
|
+
|
|
36
|
+
const { getWorkerApi, executeTask } = useWorker();
|
|
37
|
+
|
|
38
|
+
const canCancel = computed(() => isCompressing.value && !!abortController.value);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 解码图片(Worker 或浏览器内置)
|
|
42
|
+
*/
|
|
43
|
+
async function decodeImage(
|
|
44
|
+
signal: AbortSignal,
|
|
45
|
+
blob: Blob
|
|
46
|
+
): Promise<ImageData> {
|
|
47
|
+
const mimeType = await sniffMimeType(blob);
|
|
48
|
+
const canDecode = await canDecodeImageType(mimeType);
|
|
49
|
+
const workerApi = await getWorkerApi();
|
|
50
|
+
|
|
51
|
+
if (!canDecode) {
|
|
52
|
+
// 特殊格式使用 Worker 解码
|
|
53
|
+
if (mimeType === 'image/avif') {
|
|
54
|
+
return executeTask(signal, () => workerApi.avifDecode(blob));
|
|
55
|
+
}
|
|
56
|
+
if (mimeType === 'image/webp') {
|
|
57
|
+
return executeTask(signal, () => workerApi.webpDecode(blob));
|
|
58
|
+
}
|
|
59
|
+
if (mimeType === 'image/jxl') {
|
|
60
|
+
return executeTask(signal, () => workerApi.jxlDecode(blob));
|
|
61
|
+
}
|
|
62
|
+
if (mimeType === 'image/webp2') {
|
|
63
|
+
return executeTask(signal, () => workerApi.wp2Decode(blob));
|
|
64
|
+
}
|
|
65
|
+
if (mimeType === 'image/qoi') {
|
|
66
|
+
return executeTask(signal, () => workerApi.qoiDecode(blob));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 浏览器内置解码
|
|
71
|
+
return blobToImageData(blob);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 预处理(旋转)
|
|
76
|
+
*/
|
|
77
|
+
async function preprocessImage(
|
|
78
|
+
signal: AbortSignal,
|
|
79
|
+
imageData: ImageData,
|
|
80
|
+
rotate: number
|
|
81
|
+
): Promise<ImageData> {
|
|
82
|
+
if (rotate === 0) return imageData;
|
|
83
|
+
|
|
84
|
+
const workerApi = await getWorkerApi();
|
|
85
|
+
return executeTask(signal, () =>
|
|
86
|
+
workerApi.rotate(imageData, { rotate })
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 处理图片(resize / quantize)
|
|
92
|
+
*/
|
|
93
|
+
async function processImage(
|
|
94
|
+
signal: AbortSignal,
|
|
95
|
+
imageData: ImageData,
|
|
96
|
+
options: CompressionOptions
|
|
97
|
+
): Promise<ImageData> {
|
|
98
|
+
let processed = imageData;
|
|
99
|
+
const workerApi = await getWorkerApi();
|
|
100
|
+
|
|
101
|
+
// 调整尺寸
|
|
102
|
+
if (options.resize?.enabled) {
|
|
103
|
+
processed = await executeTask(signal, () =>
|
|
104
|
+
workerApi.resize(processed, {
|
|
105
|
+
width: options.resize!.width || processed.width,
|
|
106
|
+
height: options.resize!.height || processed.height,
|
|
107
|
+
method: options.resize!.method || 'lanczos3',
|
|
108
|
+
fitMethod: options.resize!.fitMethod || 'stretch',
|
|
109
|
+
premultiply: true,
|
|
110
|
+
linearRGB: true,
|
|
111
|
+
})
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 颜色量化
|
|
116
|
+
if (options.quantize?.enabled) {
|
|
117
|
+
processed = await executeTask(signal, () =>
|
|
118
|
+
workerApi.quantize(processed, {
|
|
119
|
+
numColors: options.quantize!.numColors || 256,
|
|
120
|
+
dither: options.quantize!.dither || 1.0,
|
|
121
|
+
})
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return processed;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 编码图片
|
|
130
|
+
*/
|
|
131
|
+
async function encodeImage(
|
|
132
|
+
signal: AbortSignal,
|
|
133
|
+
imageData: ImageData,
|
|
134
|
+
options: CompressionOptions
|
|
135
|
+
): Promise<ArrayBuffer> {
|
|
136
|
+
const workerApi = await getWorkerApi();
|
|
137
|
+
const encoder = options.encoder;
|
|
138
|
+
|
|
139
|
+
// 浏览器内置编码器
|
|
140
|
+
if (encoder === 'browserJPEG') {
|
|
141
|
+
const blob = await imageDataToBlob(
|
|
142
|
+
imageData,
|
|
143
|
+
'image/jpeg',
|
|
144
|
+
options.encoderOptions.quality
|
|
145
|
+
);
|
|
146
|
+
return blob.arrayBuffer();
|
|
147
|
+
}
|
|
148
|
+
if (encoder === 'browserPNG') {
|
|
149
|
+
const blob = await imageDataToBlob(imageData, 'image/png');
|
|
150
|
+
return blob.arrayBuffer();
|
|
151
|
+
}
|
|
152
|
+
if (encoder === 'browserGIF') {
|
|
153
|
+
// GIF 需要使用 Worker 或第三方库
|
|
154
|
+
throw new Error('Browser GIF encoding not supported directly');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// WASM 编码器
|
|
158
|
+
const encodeMap: Record<string, (data: ImageData, opts: any) => Promise<Uint8Array>> = {
|
|
159
|
+
mozJPEG: workerApi.mozjpegEncode,
|
|
160
|
+
webP: workerApi.webpEncode,
|
|
161
|
+
avif: workerApi.avifEncode,
|
|
162
|
+
jxl: workerApi.jxlEncode,
|
|
163
|
+
oxiPNG: workerApi.oxipngEncode,
|
|
164
|
+
qoi: workerApi.qoiEncode,
|
|
165
|
+
wp2: workerApi.wp2Encode,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const encodeFn = encodeMap[encoder];
|
|
169
|
+
if (!encodeFn) {
|
|
170
|
+
throw new Error(`Unknown encoder: ${encoder}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const encoded = await executeTask(signal, () =>
|
|
174
|
+
encodeFn.call(workerApi, imageData, options.encoderOptions)
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
return (encoded as Uint8Array).buffer as ArrayBuffer;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 主压缩流程
|
|
182
|
+
*/
|
|
183
|
+
async function compress(
|
|
184
|
+
file: File,
|
|
185
|
+
options: CompressionOptions
|
|
186
|
+
): Promise<CompressionResult> {
|
|
187
|
+
abortController.value = new AbortController();
|
|
188
|
+
const signal = abortController.value.signal;
|
|
189
|
+
|
|
190
|
+
isCompressing.value = true;
|
|
191
|
+
progress.value = 0;
|
|
192
|
+
error.value = null;
|
|
193
|
+
const previousResult = result.value;
|
|
194
|
+
result.value = null;
|
|
195
|
+
|
|
196
|
+
// 清理旧结果
|
|
197
|
+
if (previousResult?.compressed?.blobUrl) {
|
|
198
|
+
revokeBlobUrl(previousResult.compressed.blobUrl);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
// 1. 解码
|
|
203
|
+
progress.value = 10;
|
|
204
|
+
const originalInfo: ImageInfo = {
|
|
205
|
+
file,
|
|
206
|
+
size: file.size,
|
|
207
|
+
width: 0,
|
|
208
|
+
height: 0,
|
|
209
|
+
blobUrl: createBlobUrl(file),
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const decoded = await decodeImage(signal, file);
|
|
213
|
+
originalInfo.width = decoded.width;
|
|
214
|
+
originalInfo.height = decoded.height;
|
|
215
|
+
progress.value = 25;
|
|
216
|
+
|
|
217
|
+
// 2. 预处理
|
|
218
|
+
const preprocessed = await preprocessImage(
|
|
219
|
+
signal,
|
|
220
|
+
decoded,
|
|
221
|
+
options.rotate || 0
|
|
222
|
+
);
|
|
223
|
+
progress.value = 35;
|
|
224
|
+
|
|
225
|
+
// 3. 处理
|
|
226
|
+
const processed = await processImage(signal, preprocessed, options);
|
|
227
|
+
progress.value = 50;
|
|
228
|
+
|
|
229
|
+
// 4. 编码
|
|
230
|
+
const encodedBuffer = await encodeImage(signal, processed, options);
|
|
231
|
+
progress.value = 80;
|
|
232
|
+
|
|
233
|
+
// 5. 组装结果
|
|
234
|
+
const meta = ENCODER_REGISTRY[options.encoder as keyof typeof ENCODER_REGISTRY];
|
|
235
|
+
const compressedFilename = generateCompressedFilename(
|
|
236
|
+
file.name,
|
|
237
|
+
meta?.extension || 'bin'
|
|
238
|
+
);
|
|
239
|
+
const compressedFile = arrayBufferToFile(
|
|
240
|
+
encodedBuffer,
|
|
241
|
+
compressedFilename,
|
|
242
|
+
meta?.mimeType || 'application/octet-stream'
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const compressedInfo: ImageInfo = {
|
|
246
|
+
file: compressedFile,
|
|
247
|
+
size: compressedFile.size,
|
|
248
|
+
width: processed.width,
|
|
249
|
+
height: processed.height,
|
|
250
|
+
blobUrl: createBlobUrl(compressedFile),
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const compressionResult: CompressionResult = {
|
|
254
|
+
original: originalInfo,
|
|
255
|
+
compressed: compressedInfo,
|
|
256
|
+
savingsBytes: originalInfo.size - compressedInfo.size,
|
|
257
|
+
savingsPercent: calculateSavings(originalInfo.size, compressedInfo.size),
|
|
258
|
+
encoderType: options.encoder,
|
|
259
|
+
encoderOptions: options.encoderOptions,
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
result.value = compressionResult;
|
|
263
|
+
progress.value = 100;
|
|
264
|
+
return compressionResult;
|
|
265
|
+
} catch (err) {
|
|
266
|
+
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
267
|
+
error.value = '已取消';
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
const message = err instanceof Error ? err.message : '压缩失败';
|
|
271
|
+
error.value = message;
|
|
272
|
+
console.error('Compression error:', err);
|
|
273
|
+
throw err;
|
|
274
|
+
} finally {
|
|
275
|
+
isCompressing.value = false;
|
|
276
|
+
abortController.value = null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* 取消压缩
|
|
282
|
+
*/
|
|
283
|
+
function cancel() {
|
|
284
|
+
if (abortController.value) {
|
|
285
|
+
abortController.value.abort();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 下载压缩结果
|
|
291
|
+
*/
|
|
292
|
+
function downloadResult() {
|
|
293
|
+
if (!result.value) return;
|
|
294
|
+
|
|
295
|
+
const { compressed } = result.value;
|
|
296
|
+
const a = document.createElement('a');
|
|
297
|
+
a.href = compressed.blobUrl;
|
|
298
|
+
a.download = compressed.file.name;
|
|
299
|
+
document.body.appendChild(a);
|
|
300
|
+
a.click();
|
|
301
|
+
document.body.removeChild(a);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
compress,
|
|
306
|
+
cancel,
|
|
307
|
+
downloadResult,
|
|
308
|
+
isCompressing,
|
|
309
|
+
progress,
|
|
310
|
+
error,
|
|
311
|
+
result,
|
|
312
|
+
canCancel,
|
|
313
|
+
};
|
|
314
|
+
}
|