qasai 0.0.1
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/.commandcode/taste/cli/taste.md +22 -0
- package/.commandcode/taste/taste.md +4 -0
- package/.pnpmrc.json +13 -0
- package/.prettierrc +10 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +190 -0
- package/README.md +290 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +879 -0
- package/package.json +59 -0
- package/src/commands/compress.ts +115 -0
- package/src/commands/interactive.ts +318 -0
- package/src/index.ts +61 -0
- package/src/types/bins.d.ts +24 -0
- package/src/utils/banner.ts +23 -0
- package/src/utils/compressor.ts +437 -0
- package/src/utils/engines.ts +220 -0
- package/src/utils/types.ts +35 -0
- package/tsconfig.json +17 -0
- package/tsup.config.ts +14 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import { optimize } from 'svgo';
|
|
3
|
+
import { readFile, writeFile, stat, mkdir, copyFile } from 'fs/promises';
|
|
4
|
+
import { dirname, extname, basename, join } from 'path';
|
|
5
|
+
import type { CompressOptions, CompressionResult, ImageFormat } from './types.js';
|
|
6
|
+
import {
|
|
7
|
+
compressWithMozjpeg,
|
|
8
|
+
compressWithJpegtran,
|
|
9
|
+
compressWithPngquant,
|
|
10
|
+
compressWithOptipng,
|
|
11
|
+
compressWithGifsicle
|
|
12
|
+
} from './engines.js';
|
|
13
|
+
|
|
14
|
+
const SUPPORTED_FORMATS = ['.jpg', '.jpeg', '.png', '.webp', '.avif', '.gif', '.tiff', '.svg'];
|
|
15
|
+
|
|
16
|
+
export function isSupportedFormat(file: string): boolean {
|
|
17
|
+
const ext = extname(file).toLowerCase();
|
|
18
|
+
return SUPPORTED_FORMATS.includes(ext);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getFormat(file: string): ImageFormat {
|
|
22
|
+
const ext = extname(file).toLowerCase().slice(1);
|
|
23
|
+
return ext as ImageFormat;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseResize(resize: string): { width?: number; height?: number; percent?: number } {
|
|
27
|
+
if (resize.endsWith('%')) {
|
|
28
|
+
return { percent: parseInt(resize) / 100 };
|
|
29
|
+
}
|
|
30
|
+
const [w, h] = resize.split('x').map(Number);
|
|
31
|
+
return { width: w || undefined, height: h || undefined };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function compressSvg(
|
|
35
|
+
inputPath: string,
|
|
36
|
+
outputPath: string,
|
|
37
|
+
options: CompressOptions
|
|
38
|
+
): Promise<CompressionResult> {
|
|
39
|
+
const originalContent = await readFile(inputPath, 'utf-8');
|
|
40
|
+
const originalSize = Buffer.byteLength(originalContent, 'utf-8');
|
|
41
|
+
|
|
42
|
+
const result = optimize(originalContent, {
|
|
43
|
+
multipass: true,
|
|
44
|
+
plugins: [
|
|
45
|
+
'preset-default',
|
|
46
|
+
'removeDimensions',
|
|
47
|
+
{
|
|
48
|
+
name: 'removeAttrs',
|
|
49
|
+
params: {
|
|
50
|
+
attrs: options.keepMetadata ? [] : ['data-.*']
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
57
|
+
await writeFile(outputPath, result.data);
|
|
58
|
+
|
|
59
|
+
const compressedSize = Buffer.byteLength(result.data, 'utf-8');
|
|
60
|
+
const saved = originalSize - compressedSize;
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
file: inputPath,
|
|
64
|
+
originalSize,
|
|
65
|
+
compressedSize,
|
|
66
|
+
saved,
|
|
67
|
+
savedPercent: (saved / originalSize) * 100
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function needsResize(options: CompressOptions): Promise<boolean> {
|
|
72
|
+
return !!(options.resize || options.maxWidth || options.maxHeight);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function resizeWithSharp(inputPath: string, options: CompressOptions): Promise<Buffer> {
|
|
76
|
+
let image = sharp(inputPath);
|
|
77
|
+
const metadata = await image.metadata();
|
|
78
|
+
|
|
79
|
+
if (options.resize) {
|
|
80
|
+
const { width, height, percent } = parseResize(options.resize);
|
|
81
|
+
if (percent && metadata.width && metadata.height) {
|
|
82
|
+
image = image.resize(
|
|
83
|
+
Math.round(metadata.width * percent),
|
|
84
|
+
Math.round(metadata.height * percent)
|
|
85
|
+
);
|
|
86
|
+
} else if (width || height) {
|
|
87
|
+
image = image.resize(width, height, { fit: 'inside', withoutEnlargement: true });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (options.maxWidth || options.maxHeight) {
|
|
92
|
+
const maxW = options.maxWidth ? parseInt(options.maxWidth) : undefined;
|
|
93
|
+
const maxH = options.maxHeight ? parseInt(options.maxHeight) : undefined;
|
|
94
|
+
image = image.resize(maxW, maxH, { fit: 'inside', withoutEnlargement: true });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return image.toBuffer();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function compressJpeg(
|
|
101
|
+
inputPath: string,
|
|
102
|
+
outputPath: string,
|
|
103
|
+
options: CompressOptions
|
|
104
|
+
): Promise<CompressionResult> {
|
|
105
|
+
const engine = options.jpegEngine || 'mozjpeg';
|
|
106
|
+
let actualInput = inputPath;
|
|
107
|
+
|
|
108
|
+
if (await needsResize(options)) {
|
|
109
|
+
const buffer = await resizeWithSharp(inputPath, options);
|
|
110
|
+
const tempPath = outputPath + '.tmp.jpg';
|
|
111
|
+
await mkdir(dirname(tempPath), { recursive: true });
|
|
112
|
+
await sharp(buffer).jpeg({ quality: 100 }).toFile(tempPath);
|
|
113
|
+
actualInput = tempPath;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let result: CompressionResult;
|
|
117
|
+
|
|
118
|
+
switch (engine) {
|
|
119
|
+
case 'mozjpeg':
|
|
120
|
+
result = await compressWithMozjpeg(actualInput, outputPath, options);
|
|
121
|
+
break;
|
|
122
|
+
case 'jpegtran':
|
|
123
|
+
result = await compressWithJpegtran(actualInput, outputPath, options);
|
|
124
|
+
break;
|
|
125
|
+
case 'sharp':
|
|
126
|
+
default:
|
|
127
|
+
result = await compressWithSharpJpeg(actualInput, outputPath, options);
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (actualInput !== inputPath) {
|
|
132
|
+
await import('fs/promises').then(fs => fs.unlink(actualInput).catch(() => {}));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
result.file = inputPath;
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function compressWithSharpJpeg(
|
|
140
|
+
inputPath: string,
|
|
141
|
+
outputPath: string,
|
|
142
|
+
options: CompressOptions
|
|
143
|
+
): Promise<CompressionResult> {
|
|
144
|
+
const originalStats = await stat(inputPath);
|
|
145
|
+
const originalSize = originalStats.size;
|
|
146
|
+
|
|
147
|
+
const quality = parseInt(options.quality || '80');
|
|
148
|
+
|
|
149
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
150
|
+
|
|
151
|
+
let image = sharp(inputPath);
|
|
152
|
+
|
|
153
|
+
if (!options.keepMetadata) {
|
|
154
|
+
image = image.rotate();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
await image
|
|
158
|
+
.jpeg({
|
|
159
|
+
quality: options.lossless ? 100 : quality,
|
|
160
|
+
progressive: options.progressive !== false,
|
|
161
|
+
mozjpeg: true
|
|
162
|
+
})
|
|
163
|
+
.toFile(outputPath);
|
|
164
|
+
|
|
165
|
+
const compressedStats = await stat(outputPath);
|
|
166
|
+
const compressedSize = compressedStats.size;
|
|
167
|
+
const saved = originalSize - compressedSize;
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
file: inputPath,
|
|
171
|
+
originalSize,
|
|
172
|
+
compressedSize,
|
|
173
|
+
saved,
|
|
174
|
+
savedPercent: (saved / originalSize) * 100
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function compressPng(
|
|
179
|
+
inputPath: string,
|
|
180
|
+
outputPath: string,
|
|
181
|
+
options: CompressOptions
|
|
182
|
+
): Promise<CompressionResult> {
|
|
183
|
+
const engine = options.pngEngine || 'pngquant';
|
|
184
|
+
let actualInput = inputPath;
|
|
185
|
+
|
|
186
|
+
if (await needsResize(options)) {
|
|
187
|
+
const buffer = await resizeWithSharp(inputPath, options);
|
|
188
|
+
const tempPath = outputPath + '.tmp.png';
|
|
189
|
+
await mkdir(dirname(tempPath), { recursive: true });
|
|
190
|
+
await sharp(buffer).png().toFile(tempPath);
|
|
191
|
+
actualInput = tempPath;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let result: CompressionResult;
|
|
195
|
+
|
|
196
|
+
switch (engine) {
|
|
197
|
+
case 'pngquant':
|
|
198
|
+
result = await compressWithPngquant(actualInput, outputPath, options);
|
|
199
|
+
break;
|
|
200
|
+
case 'optipng':
|
|
201
|
+
result = await compressWithOptipng(actualInput, outputPath, options);
|
|
202
|
+
break;
|
|
203
|
+
case 'sharp':
|
|
204
|
+
default:
|
|
205
|
+
result = await compressWithSharpPng(actualInput, outputPath, options);
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (actualInput !== inputPath) {
|
|
210
|
+
await import('fs/promises').then(fs => fs.unlink(actualInput).catch(() => {}));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
result.file = inputPath;
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function compressWithSharpPng(
|
|
218
|
+
inputPath: string,
|
|
219
|
+
outputPath: string,
|
|
220
|
+
options: CompressOptions
|
|
221
|
+
): Promise<CompressionResult> {
|
|
222
|
+
const originalStats = await stat(inputPath);
|
|
223
|
+
const originalSize = originalStats.size;
|
|
224
|
+
|
|
225
|
+
const quality = parseInt(options.quality || '80');
|
|
226
|
+
const effort = parseInt(options.effort || '6');
|
|
227
|
+
|
|
228
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
229
|
+
|
|
230
|
+
let image = sharp(inputPath);
|
|
231
|
+
|
|
232
|
+
if (!options.keepMetadata) {
|
|
233
|
+
image = image.rotate();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
await image
|
|
237
|
+
.png({
|
|
238
|
+
compressionLevel: Math.min(9, Math.round(effort * 0.9)),
|
|
239
|
+
palette: !options.lossless,
|
|
240
|
+
quality: options.lossless ? 100 : quality,
|
|
241
|
+
effort
|
|
242
|
+
})
|
|
243
|
+
.toFile(outputPath);
|
|
244
|
+
|
|
245
|
+
const compressedStats = await stat(outputPath);
|
|
246
|
+
const compressedSize = compressedStats.size;
|
|
247
|
+
const saved = originalSize - compressedSize;
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
file: inputPath,
|
|
251
|
+
originalSize,
|
|
252
|
+
compressedSize,
|
|
253
|
+
saved,
|
|
254
|
+
savedPercent: (saved / originalSize) * 100
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function compressGif(
|
|
259
|
+
inputPath: string,
|
|
260
|
+
outputPath: string,
|
|
261
|
+
options: CompressOptions
|
|
262
|
+
): Promise<CompressionResult> {
|
|
263
|
+
const engine = options.gifEngine || 'gifsicle';
|
|
264
|
+
|
|
265
|
+
if (engine === 'gifsicle') {
|
|
266
|
+
return compressWithGifsicle(inputPath, outputPath, options);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return compressWithSharpGif(inputPath, outputPath, options);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function compressWithSharpGif(
|
|
273
|
+
inputPath: string,
|
|
274
|
+
outputPath: string,
|
|
275
|
+
options: CompressOptions
|
|
276
|
+
): Promise<CompressionResult> {
|
|
277
|
+
const originalStats = await stat(inputPath);
|
|
278
|
+
const originalSize = originalStats.size;
|
|
279
|
+
|
|
280
|
+
const effort = parseInt(options.effort || '6');
|
|
281
|
+
|
|
282
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
283
|
+
|
|
284
|
+
await sharp(inputPath, { animated: true })
|
|
285
|
+
.gif({ effort })
|
|
286
|
+
.toFile(outputPath);
|
|
287
|
+
|
|
288
|
+
const compressedStats = await stat(outputPath);
|
|
289
|
+
const compressedSize = compressedStats.size;
|
|
290
|
+
const saved = originalSize - compressedSize;
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
file: inputPath,
|
|
294
|
+
originalSize,
|
|
295
|
+
compressedSize,
|
|
296
|
+
saved,
|
|
297
|
+
savedPercent: (saved / originalSize) * 100
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function compressRaster(
|
|
302
|
+
inputPath: string,
|
|
303
|
+
outputPath: string,
|
|
304
|
+
options: CompressOptions
|
|
305
|
+
): Promise<CompressionResult> {
|
|
306
|
+
const originalStats = await stat(inputPath);
|
|
307
|
+
const originalSize = originalStats.size;
|
|
308
|
+
|
|
309
|
+
let image = sharp(inputPath);
|
|
310
|
+
const metadata = await image.metadata();
|
|
311
|
+
|
|
312
|
+
const quality = parseInt(options.quality || '80');
|
|
313
|
+
const effort = parseInt(options.effort || '6');
|
|
314
|
+
|
|
315
|
+
if (options.resize) {
|
|
316
|
+
const { width, height, percent } = parseResize(options.resize);
|
|
317
|
+
if (percent && metadata.width && metadata.height) {
|
|
318
|
+
image = image.resize(
|
|
319
|
+
Math.round(metadata.width * percent),
|
|
320
|
+
Math.round(metadata.height * percent)
|
|
321
|
+
);
|
|
322
|
+
} else if (width || height) {
|
|
323
|
+
image = image.resize(width, height, { fit: 'inside', withoutEnlargement: true });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (options.maxWidth || options.maxHeight) {
|
|
328
|
+
const maxW = options.maxWidth ? parseInt(options.maxWidth) : undefined;
|
|
329
|
+
const maxH = options.maxHeight ? parseInt(options.maxHeight) : undefined;
|
|
330
|
+
image = image.resize(maxW, maxH, { fit: 'inside', withoutEnlargement: true });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (!options.keepMetadata) {
|
|
334
|
+
image = image.rotate();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const inputFormat = getFormat(inputPath);
|
|
338
|
+
const outputFormat = options.format || (inputFormat === 'jpeg' ? 'jpg' : inputFormat as string);
|
|
339
|
+
|
|
340
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
341
|
+
|
|
342
|
+
switch (outputFormat as string) {
|
|
343
|
+
case 'webp':
|
|
344
|
+
await image
|
|
345
|
+
.webp({
|
|
346
|
+
quality: options.lossless ? 100 : quality,
|
|
347
|
+
lossless: options.lossless || false,
|
|
348
|
+
effort
|
|
349
|
+
})
|
|
350
|
+
.toFile(outputPath);
|
|
351
|
+
break;
|
|
352
|
+
|
|
353
|
+
case 'avif':
|
|
354
|
+
await image
|
|
355
|
+
.avif({
|
|
356
|
+
quality: options.lossless ? 100 : quality,
|
|
357
|
+
lossless: options.lossless || false,
|
|
358
|
+
effort
|
|
359
|
+
})
|
|
360
|
+
.toFile(outputPath);
|
|
361
|
+
break;
|
|
362
|
+
|
|
363
|
+
case 'tiff':
|
|
364
|
+
await image
|
|
365
|
+
.tiff({
|
|
366
|
+
quality: options.lossless ? 100 : quality,
|
|
367
|
+
compression: options.lossless ? 'lzw' : 'jpeg'
|
|
368
|
+
})
|
|
369
|
+
.toFile(outputPath);
|
|
370
|
+
break;
|
|
371
|
+
|
|
372
|
+
default:
|
|
373
|
+
await copyFile(inputPath, outputPath);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const compressedStats = await stat(outputPath);
|
|
377
|
+
const compressedSize = compressedStats.size;
|
|
378
|
+
const saved = originalSize - compressedSize;
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
file: inputPath,
|
|
382
|
+
originalSize,
|
|
383
|
+
compressedSize,
|
|
384
|
+
saved,
|
|
385
|
+
savedPercent: (saved / originalSize) * 100
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export async function compressImage(
|
|
390
|
+
inputPath: string,
|
|
391
|
+
outputPath: string,
|
|
392
|
+
options: CompressOptions
|
|
393
|
+
): Promise<CompressionResult> {
|
|
394
|
+
const format = getFormat(inputPath);
|
|
395
|
+
|
|
396
|
+
let finalOutputPath = outputPath;
|
|
397
|
+
if (options.format && options.format !== format) {
|
|
398
|
+
const dir = dirname(outputPath);
|
|
399
|
+
const name = basename(outputPath, extname(outputPath));
|
|
400
|
+
finalOutputPath = join(dir, `${name}.${options.format}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
switch (format) {
|
|
404
|
+
case 'svg':
|
|
405
|
+
return compressSvg(inputPath, finalOutputPath, options);
|
|
406
|
+
|
|
407
|
+
case 'jpg':
|
|
408
|
+
case 'jpeg':
|
|
409
|
+
if (!options.format || options.format === 'jpg') {
|
|
410
|
+
return compressJpeg(inputPath, finalOutputPath, options);
|
|
411
|
+
}
|
|
412
|
+
return compressRaster(inputPath, finalOutputPath, options);
|
|
413
|
+
|
|
414
|
+
case 'png':
|
|
415
|
+
if (!options.format || options.format === 'png') {
|
|
416
|
+
return compressPng(inputPath, finalOutputPath, options);
|
|
417
|
+
}
|
|
418
|
+
return compressRaster(inputPath, finalOutputPath, options);
|
|
419
|
+
|
|
420
|
+
case 'gif':
|
|
421
|
+
if (!options.format) {
|
|
422
|
+
return compressGif(inputPath, finalOutputPath, options);
|
|
423
|
+
}
|
|
424
|
+
return compressRaster(inputPath, finalOutputPath, options);
|
|
425
|
+
|
|
426
|
+
default:
|
|
427
|
+
return compressRaster(inputPath, finalOutputPath, options);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export function formatBytes(bytes: number): string {
|
|
432
|
+
if (bytes === 0) return '0 B';
|
|
433
|
+
const k = 1024;
|
|
434
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
435
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
436
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
|
437
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import { stat, copyFile, mkdir, readFile, writeFile } from 'fs/promises';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { randomUUID } from 'crypto';
|
|
6
|
+
import type { CompressOptions, CompressionResult } from './types.js';
|
|
7
|
+
|
|
8
|
+
async function getTempPath(ext: string): Promise<string> {
|
|
9
|
+
const tempDir = join(tmpdir(), 'qasai');
|
|
10
|
+
await mkdir(tempDir, { recursive: true });
|
|
11
|
+
return join(tempDir, `${randomUUID()}${ext}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function compressWithMozjpeg(
|
|
15
|
+
inputPath: string,
|
|
16
|
+
outputPath: string,
|
|
17
|
+
options: CompressOptions
|
|
18
|
+
): Promise<CompressionResult> {
|
|
19
|
+
const originalStats = await stat(inputPath);
|
|
20
|
+
const originalSize = originalStats.size;
|
|
21
|
+
|
|
22
|
+
const mozjpeg = await import('mozjpeg');
|
|
23
|
+
const quality = parseInt(options.quality || '80');
|
|
24
|
+
|
|
25
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
26
|
+
|
|
27
|
+
const args = [
|
|
28
|
+
'-quality', options.lossless ? '100' : String(quality),
|
|
29
|
+
'-outfile', outputPath,
|
|
30
|
+
inputPath
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
if (options.progressive !== false) {
|
|
34
|
+
args.unshift('-progressive');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
await execa(mozjpeg.default, args);
|
|
38
|
+
|
|
39
|
+
const compressedStats = await stat(outputPath);
|
|
40
|
+
const compressedSize = compressedStats.size;
|
|
41
|
+
const saved = originalSize - compressedSize;
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
file: inputPath,
|
|
45
|
+
originalSize,
|
|
46
|
+
compressedSize,
|
|
47
|
+
saved,
|
|
48
|
+
savedPercent: (saved / originalSize) * 100
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function compressWithJpegtran(
|
|
53
|
+
inputPath: string,
|
|
54
|
+
outputPath: string,
|
|
55
|
+
options: CompressOptions
|
|
56
|
+
): Promise<CompressionResult> {
|
|
57
|
+
const originalStats = await stat(inputPath);
|
|
58
|
+
const originalSize = originalStats.size;
|
|
59
|
+
|
|
60
|
+
const jpegtran = await import('jpegtran-bin');
|
|
61
|
+
|
|
62
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
63
|
+
|
|
64
|
+
const args = [
|
|
65
|
+
'-optimize',
|
|
66
|
+
'-outfile', outputPath
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
if (options.progressive !== false) {
|
|
70
|
+
args.push('-progressive');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!options.keepMetadata) {
|
|
74
|
+
args.push('-copy', 'none');
|
|
75
|
+
} else {
|
|
76
|
+
args.push('-copy', 'all');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
args.push(inputPath);
|
|
80
|
+
|
|
81
|
+
await execa(jpegtran.default, args);
|
|
82
|
+
|
|
83
|
+
const compressedStats = await stat(outputPath);
|
|
84
|
+
const compressedSize = compressedStats.size;
|
|
85
|
+
const saved = originalSize - compressedSize;
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
file: inputPath,
|
|
89
|
+
originalSize,
|
|
90
|
+
compressedSize,
|
|
91
|
+
saved,
|
|
92
|
+
savedPercent: (saved / originalSize) * 100
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function compressWithPngquant(
|
|
97
|
+
inputPath: string,
|
|
98
|
+
outputPath: string,
|
|
99
|
+
options: CompressOptions
|
|
100
|
+
): Promise<CompressionResult> {
|
|
101
|
+
const originalStats = await stat(inputPath);
|
|
102
|
+
const originalSize = originalStats.size;
|
|
103
|
+
|
|
104
|
+
const pngquant = await import('pngquant-bin');
|
|
105
|
+
const quality = options.pngQuality || options.quality || '65-80';
|
|
106
|
+
const colors = parseInt(options.colors || '256');
|
|
107
|
+
|
|
108
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
109
|
+
|
|
110
|
+
const args = [
|
|
111
|
+
'--quality', quality.includes('-') ? quality : `0-${quality}`,
|
|
112
|
+
'--speed', '1',
|
|
113
|
+
'--force',
|
|
114
|
+
colors.toString(),
|
|
115
|
+
'--output', outputPath,
|
|
116
|
+
inputPath
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await execa(pngquant.default, args);
|
|
121
|
+
} catch (error: unknown) {
|
|
122
|
+
const execaError = error as { exitCode?: number };
|
|
123
|
+
if (execaError.exitCode === 99) {
|
|
124
|
+
await copyFile(inputPath, outputPath);
|
|
125
|
+
} else {
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const compressedStats = await stat(outputPath);
|
|
131
|
+
const compressedSize = compressedStats.size;
|
|
132
|
+
const saved = originalSize - compressedSize;
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
file: inputPath,
|
|
136
|
+
originalSize,
|
|
137
|
+
compressedSize,
|
|
138
|
+
saved,
|
|
139
|
+
savedPercent: (saved / originalSize) * 100
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function compressWithOptipng(
|
|
144
|
+
inputPath: string,
|
|
145
|
+
outputPath: string,
|
|
146
|
+
options: CompressOptions
|
|
147
|
+
): Promise<CompressionResult> {
|
|
148
|
+
const originalStats = await stat(inputPath);
|
|
149
|
+
const originalSize = originalStats.size;
|
|
150
|
+
|
|
151
|
+
const optipng = await import('optipng-bin');
|
|
152
|
+
const effort = parseInt(options.effort || '2');
|
|
153
|
+
|
|
154
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
155
|
+
await copyFile(inputPath, outputPath);
|
|
156
|
+
|
|
157
|
+
const args = [
|
|
158
|
+
`-o${Math.min(7, effort)}`,
|
|
159
|
+
'-silent',
|
|
160
|
+
outputPath
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
if (!options.keepMetadata) {
|
|
164
|
+
args.push('-strip', 'all');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await execa(optipng.default, args);
|
|
168
|
+
|
|
169
|
+
const compressedStats = await stat(outputPath);
|
|
170
|
+
const compressedSize = compressedStats.size;
|
|
171
|
+
const saved = originalSize - compressedSize;
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
file: inputPath,
|
|
175
|
+
originalSize,
|
|
176
|
+
compressedSize,
|
|
177
|
+
saved,
|
|
178
|
+
savedPercent: (saved / originalSize) * 100
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function compressWithGifsicle(
|
|
183
|
+
inputPath: string,
|
|
184
|
+
outputPath: string,
|
|
185
|
+
options: CompressOptions
|
|
186
|
+
): Promise<CompressionResult> {
|
|
187
|
+
const originalStats = await stat(inputPath);
|
|
188
|
+
const originalSize = originalStats.size;
|
|
189
|
+
|
|
190
|
+
const gifsicle = await import('gifsicle');
|
|
191
|
+
const colors = parseInt(options.colors || '256');
|
|
192
|
+
const effort = parseInt(options.effort || '3');
|
|
193
|
+
|
|
194
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
195
|
+
|
|
196
|
+
const args = [
|
|
197
|
+
`-O${Math.min(3, effort)}`,
|
|
198
|
+
'--colors', String(colors),
|
|
199
|
+
'-o', outputPath,
|
|
200
|
+
inputPath
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
if (options.lossless) {
|
|
204
|
+
args.splice(args.indexOf('--colors'), 2);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
await execa(gifsicle.default, args);
|
|
208
|
+
|
|
209
|
+
const compressedStats = await stat(outputPath);
|
|
210
|
+
const compressedSize = compressedStats.size;
|
|
211
|
+
const saved = originalSize - compressedSize;
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
file: inputPath,
|
|
215
|
+
originalSize,
|
|
216
|
+
compressedSize,
|
|
217
|
+
saved,
|
|
218
|
+
savedPercent: (saved / originalSize) * 100
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type JpegEngine = 'mozjpeg' | 'jpegtran' | 'sharp';
|
|
2
|
+
export type PngEngine = 'pngquant' | 'optipng' | 'sharp';
|
|
3
|
+
export type GifEngine = 'gifsicle' | 'sharp';
|
|
4
|
+
export type SvgEngine = 'svgo';
|
|
5
|
+
|
|
6
|
+
export interface CompressOptions {
|
|
7
|
+
output?: string;
|
|
8
|
+
inPlace?: boolean;
|
|
9
|
+
quality?: string;
|
|
10
|
+
lossless?: boolean;
|
|
11
|
+
resize?: string;
|
|
12
|
+
maxWidth?: string;
|
|
13
|
+
maxHeight?: string;
|
|
14
|
+
format?: 'jpg' | 'png' | 'webp' | 'avif';
|
|
15
|
+
recursive?: boolean;
|
|
16
|
+
keepMetadata?: boolean;
|
|
17
|
+
progressive?: boolean;
|
|
18
|
+
effort?: string;
|
|
19
|
+
jpegEngine?: JpegEngine;
|
|
20
|
+
pngEngine?: PngEngine;
|
|
21
|
+
gifEngine?: GifEngine;
|
|
22
|
+
pngQuality?: string;
|
|
23
|
+
colors?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CompressionResult {
|
|
27
|
+
file: string;
|
|
28
|
+
originalSize: number;
|
|
29
|
+
compressedSize: number;
|
|
30
|
+
saved: number;
|
|
31
|
+
savedPercent: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ImageFormat = 'jpg' | 'jpeg' | 'png' | 'webp' | 'avif' | 'gif' | 'svg' | 'tiff';
|
|
35
|
+
export type OutputFormat = 'jpg' | 'png' | 'webp' | 'avif' | 'gif' | 'tiff';
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"rootDir": "src",
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"allowSyntheticDefaultImports": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*"],
|
|
16
|
+
"exclude": ["node_modules", "dist"]
|
|
17
|
+
}
|