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
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "qasai",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Image compression CLI with lossless and lossy options",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"qasai": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsup",
|
|
11
|
+
"dev": "tsup --watch",
|
|
12
|
+
"start": "node dist/index.js",
|
|
13
|
+
"test": "vitest",
|
|
14
|
+
"typecheck": "tsc --noEmit"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"image",
|
|
18
|
+
"compression",
|
|
19
|
+
"cli",
|
|
20
|
+
"optimize",
|
|
21
|
+
"jpg",
|
|
22
|
+
"png",
|
|
23
|
+
"svg",
|
|
24
|
+
"webp"
|
|
25
|
+
],
|
|
26
|
+
"license": "Apache-2.0",
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^22.10.0",
|
|
29
|
+
"tsup": "^8.3.5",
|
|
30
|
+
"typescript": "^5.7.2",
|
|
31
|
+
"vitest": "^2.1.8"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@clack/prompts": "^0.8.2",
|
|
35
|
+
"commander": "^12.1.0",
|
|
36
|
+
"execa": "^9.5.2",
|
|
37
|
+
"gifsicle": "^7.0.1",
|
|
38
|
+
"glob": "^11.0.0",
|
|
39
|
+
"jpegtran-bin": "^7.0.0",
|
|
40
|
+
"mozjpeg": "^8.0.0",
|
|
41
|
+
"optipng-bin": "^8.0.0",
|
|
42
|
+
"ora": "^8.1.1",
|
|
43
|
+
"picocolors": "^1.1.1",
|
|
44
|
+
"pngquant-bin": "^9.0.0",
|
|
45
|
+
"sharp": "^0.33.5",
|
|
46
|
+
"svgo": "^3.3.2"
|
|
47
|
+
},
|
|
48
|
+
"pnpm": {
|
|
49
|
+
"onlyBuiltDependencies": [
|
|
50
|
+
"esbuild",
|
|
51
|
+
"gifsicle",
|
|
52
|
+
"jpegtran-bin",
|
|
53
|
+
"mozjpeg",
|
|
54
|
+
"optipng-bin",
|
|
55
|
+
"pngquant-bin",
|
|
56
|
+
"sharp"
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { glob } from 'glob';
|
|
2
|
+
import { stat, mkdir } from 'fs/promises';
|
|
3
|
+
import { join, dirname, basename, relative, resolve } from 'path';
|
|
4
|
+
import ora from 'ora';
|
|
5
|
+
import pc from 'picocolors';
|
|
6
|
+
import { compressImage, isSupportedFormat, formatBytes } from '../utils/compressor.js';
|
|
7
|
+
import type { CompressOptions, CompressionResult } from '../utils/types.js';
|
|
8
|
+
|
|
9
|
+
async function findImages(input: string, recursive: boolean): Promise<string[]> {
|
|
10
|
+
const inputStat = await stat(input);
|
|
11
|
+
|
|
12
|
+
if (inputStat.isFile()) {
|
|
13
|
+
return isSupportedFormat(input) ? [input] : [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const pattern = recursive
|
|
17
|
+
? '**/*.{jpg,jpeg,png,webp,avif,gif,tiff,svg,JPG,JPEG,PNG,WEBP,AVIF,GIF,TIFF,SVG}'
|
|
18
|
+
: '*.{jpg,jpeg,png,webp,avif,gif,tiff,svg,JPG,JPEG,PNG,WEBP,AVIF,GIF,TIFF,SVG}';
|
|
19
|
+
|
|
20
|
+
return glob(pattern, { cwd: input, absolute: true, nodir: true });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getOutputPath(
|
|
24
|
+
inputFile: string,
|
|
25
|
+
inputDir: string,
|
|
26
|
+
outputDir: string,
|
|
27
|
+
inPlace: boolean
|
|
28
|
+
): string {
|
|
29
|
+
if (inPlace) {
|
|
30
|
+
return inputFile;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const relativePath = relative(inputDir, inputFile);
|
|
34
|
+
return join(outputDir, relativePath);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function compress(input: string, options: CompressOptions): Promise<void> {
|
|
38
|
+
const inputPath = resolve(input);
|
|
39
|
+
const inputStat = await stat(inputPath).catch(() => null);
|
|
40
|
+
|
|
41
|
+
if (!inputStat) {
|
|
42
|
+
console.error(pc.red(`Error: Path not found: ${inputPath}`));
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const inputDir = inputStat.isDirectory() ? inputPath : dirname(inputPath);
|
|
47
|
+
const outputDir = options.inPlace
|
|
48
|
+
? inputDir
|
|
49
|
+
: options.output
|
|
50
|
+
? resolve(options.output)
|
|
51
|
+
: join(inputDir, 'Kasai');
|
|
52
|
+
|
|
53
|
+
const spinner = ora('Finding images...').start();
|
|
54
|
+
|
|
55
|
+
const images = await findImages(inputPath, options.recursive || false);
|
|
56
|
+
|
|
57
|
+
if (images.length === 0) {
|
|
58
|
+
spinner.fail('No supported images found');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
spinner.succeed(`Found ${images.length} image${images.length > 1 ? 's' : ''}`);
|
|
63
|
+
|
|
64
|
+
if (!options.inPlace) {
|
|
65
|
+
await mkdir(outputDir, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const results: CompressionResult[] = [];
|
|
69
|
+
let totalOriginal = 0;
|
|
70
|
+
let totalCompressed = 0;
|
|
71
|
+
|
|
72
|
+
for (let i = 0; i < images.length; i++) {
|
|
73
|
+
const image = images[i];
|
|
74
|
+
const outputPath = getOutputPath(image, inputDir, outputDir, options.inPlace || false);
|
|
75
|
+
const fileName = basename(image);
|
|
76
|
+
|
|
77
|
+
const progress = pc.dim(`[${i + 1}/${images.length}]`);
|
|
78
|
+
const compressSpinner = ora(`${progress} Compressing ${fileName}...`).start();
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const result = await compressImage(image, outputPath, options);
|
|
82
|
+
results.push(result);
|
|
83
|
+
totalOriginal += result.originalSize;
|
|
84
|
+
totalCompressed += result.compressedSize;
|
|
85
|
+
|
|
86
|
+
const savedStr =
|
|
87
|
+
result.saved > 0
|
|
88
|
+
? pc.green(`-${result.savedPercent.toFixed(1)}%`)
|
|
89
|
+
: pc.yellow(`+${Math.abs(result.savedPercent).toFixed(1)}%`);
|
|
90
|
+
|
|
91
|
+
compressSpinner.succeed(
|
|
92
|
+
`${progress} ${fileName} ${pc.dim(formatBytes(result.originalSize))} → ${pc.dim(formatBytes(result.compressedSize))} ${savedStr}`
|
|
93
|
+
);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
96
|
+
compressSpinner.fail(`${progress} ${fileName} - ${pc.red(errorMessage)}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log('');
|
|
101
|
+
const totalSaved = totalOriginal - totalCompressed;
|
|
102
|
+
const totalPercent = totalOriginal > 0 ? (totalSaved / totalOriginal) * 100 : 0;
|
|
103
|
+
|
|
104
|
+
console.log(pc.bold('Summary:'));
|
|
105
|
+
console.log(` Files processed: ${pc.cyan(results.length.toString())}`);
|
|
106
|
+
console.log(` Original size: ${pc.dim(formatBytes(totalOriginal))}`);
|
|
107
|
+
console.log(` Compressed size: ${pc.dim(formatBytes(totalCompressed))}`);
|
|
108
|
+
console.log(
|
|
109
|
+
` Total saved: ${totalSaved > 0 ? pc.green(`${formatBytes(totalSaved)} (${totalPercent.toFixed(1)}%)`) : pc.yellow('0 B')}`
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (!options.inPlace) {
|
|
113
|
+
console.log(` Output: ${pc.cyan(outputDir)}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import { statSync } from 'fs';
|
|
5
|
+
import { compress } from './compress.js';
|
|
6
|
+
import type { CompressOptions } from '../utils/types.js';
|
|
7
|
+
|
|
8
|
+
export async function interactive(): Promise<void> {
|
|
9
|
+
p.intro(pc.bgCyan(pc.black(' QASAI Image Compression ')));
|
|
10
|
+
|
|
11
|
+
const input = await p.text({
|
|
12
|
+
message: 'Enter the path to compress',
|
|
13
|
+
placeholder: './images or ./photo.jpg',
|
|
14
|
+
defaultValue: '.',
|
|
15
|
+
validate: (value) => {
|
|
16
|
+
try {
|
|
17
|
+
const path = resolve(value);
|
|
18
|
+
statSync(path);
|
|
19
|
+
return undefined;
|
|
20
|
+
} catch {
|
|
21
|
+
return 'Path does not exist';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (p.isCancel(input)) {
|
|
27
|
+
p.cancel('Operation cancelled');
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const outputMode = await p.select({
|
|
32
|
+
message: 'Where should compressed images be saved?',
|
|
33
|
+
options: [
|
|
34
|
+
{ value: 'kasai', label: 'Kasai folder', hint: 'Creates a Kasai folder in the input directory' },
|
|
35
|
+
{ value: 'custom', label: 'Custom folder', hint: 'Specify a custom output directory' },
|
|
36
|
+
{ value: 'inplace', label: 'In place', hint: 'Overwrites original files (destructive)' }
|
|
37
|
+
]
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (p.isCancel(outputMode)) {
|
|
41
|
+
p.cancel('Operation cancelled');
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let output: string | undefined;
|
|
46
|
+
let inPlace = false;
|
|
47
|
+
|
|
48
|
+
if (outputMode === 'custom') {
|
|
49
|
+
const customOutput = await p.text({
|
|
50
|
+
message: 'Enter output directory path',
|
|
51
|
+
placeholder: './compressed'
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (p.isCancel(customOutput)) {
|
|
55
|
+
p.cancel('Operation cancelled');
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
output = customOutput;
|
|
59
|
+
} else if (outputMode === 'inplace') {
|
|
60
|
+
const confirm = await p.confirm({
|
|
61
|
+
message: 'This will overwrite original files. Are you sure?',
|
|
62
|
+
initialValue: false
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (p.isCancel(confirm) || !confirm) {
|
|
66
|
+
p.cancel('Operation cancelled');
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
inPlace = true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const compressionType = await p.select({
|
|
73
|
+
message: 'Compression type',
|
|
74
|
+
options: [
|
|
75
|
+
{ value: 'lossy', label: 'Lossy', hint: 'Smaller files, slight quality loss (recommended)' },
|
|
76
|
+
{ value: 'lossless', label: 'Lossless', hint: 'No quality loss, larger files' }
|
|
77
|
+
]
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (p.isCancel(compressionType)) {
|
|
81
|
+
p.cancel('Operation cancelled');
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let quality = '80';
|
|
86
|
+
if (compressionType === 'lossy') {
|
|
87
|
+
const qualityInput = await p.text({
|
|
88
|
+
message: 'Quality level (1-100)',
|
|
89
|
+
placeholder: '80',
|
|
90
|
+
defaultValue: '80',
|
|
91
|
+
validate: (value) => {
|
|
92
|
+
const num = parseInt(value);
|
|
93
|
+
if (isNaN(num) || num < 1 || num > 100) {
|
|
94
|
+
return 'Please enter a number between 1 and 100';
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (p.isCancel(qualityInput)) {
|
|
101
|
+
p.cancel('Operation cancelled');
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
quality = qualityInput;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const engineConfig = await p.confirm({
|
|
108
|
+
message: 'Configure compression engines? (defaults are optimal)',
|
|
109
|
+
initialValue: false
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (p.isCancel(engineConfig)) {
|
|
113
|
+
p.cancel('Operation cancelled');
|
|
114
|
+
process.exit(0);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let jpegEngine: 'mozjpeg' | 'jpegtran' | 'sharp' = 'mozjpeg';
|
|
118
|
+
let pngEngine: 'pngquant' | 'optipng' | 'sharp' = 'pngquant';
|
|
119
|
+
let gifEngine: 'gifsicle' | 'sharp' = 'gifsicle';
|
|
120
|
+
|
|
121
|
+
if (engineConfig) {
|
|
122
|
+
const jpegEngineChoice = await p.select({
|
|
123
|
+
message: 'JPEG compression engine',
|
|
124
|
+
options: [
|
|
125
|
+
{ value: 'mozjpeg', label: 'MozJPEG', hint: 'Best compression, recommended' },
|
|
126
|
+
{ value: 'jpegtran', label: 'jpegtran', hint: 'Lossless optimization only' },
|
|
127
|
+
{ value: 'sharp', label: 'Sharp', hint: 'Fast, good compression' }
|
|
128
|
+
]
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (p.isCancel(jpegEngineChoice)) {
|
|
132
|
+
p.cancel('Operation cancelled');
|
|
133
|
+
process.exit(0);
|
|
134
|
+
}
|
|
135
|
+
jpegEngine = jpegEngineChoice as typeof jpegEngine;
|
|
136
|
+
|
|
137
|
+
const pngEngineChoice = await p.select({
|
|
138
|
+
message: 'PNG compression engine',
|
|
139
|
+
options: [
|
|
140
|
+
{ value: 'pngquant', label: 'pngquant', hint: 'Best lossy compression, recommended' },
|
|
141
|
+
{ value: 'optipng', label: 'OptiPNG', hint: 'Lossless optimization' },
|
|
142
|
+
{ value: 'sharp', label: 'Sharp', hint: 'Fast, good compression' }
|
|
143
|
+
]
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
if (p.isCancel(pngEngineChoice)) {
|
|
147
|
+
p.cancel('Operation cancelled');
|
|
148
|
+
process.exit(0);
|
|
149
|
+
}
|
|
150
|
+
pngEngine = pngEngineChoice as typeof pngEngine;
|
|
151
|
+
|
|
152
|
+
const gifEngineChoice = await p.select({
|
|
153
|
+
message: 'GIF compression engine',
|
|
154
|
+
options: [
|
|
155
|
+
{ value: 'gifsicle', label: 'Gifsicle', hint: 'Best for animated GIFs, recommended' },
|
|
156
|
+
{ value: 'sharp', label: 'Sharp', hint: 'Fast, basic compression' }
|
|
157
|
+
]
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (p.isCancel(gifEngineChoice)) {
|
|
161
|
+
p.cancel('Operation cancelled');
|
|
162
|
+
process.exit(0);
|
|
163
|
+
}
|
|
164
|
+
gifEngine = gifEngineChoice as typeof gifEngine;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const advancedOptions = await p.multiselect({
|
|
168
|
+
message: 'Additional options',
|
|
169
|
+
options: [
|
|
170
|
+
{ value: 'recursive', label: 'Process subdirectories', hint: 'Include images in subfolders' },
|
|
171
|
+
{ value: 'keepMetadata', label: 'Keep metadata', hint: 'Preserve EXIF and other metadata' },
|
|
172
|
+
{ value: 'resize', label: 'Resize images', hint: 'Set maximum dimensions' }
|
|
173
|
+
],
|
|
174
|
+
required: false
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
if (p.isCancel(advancedOptions)) {
|
|
178
|
+
p.cancel('Operation cancelled');
|
|
179
|
+
process.exit(0);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const options: CompressOptions = {
|
|
183
|
+
output,
|
|
184
|
+
inPlace,
|
|
185
|
+
quality,
|
|
186
|
+
lossless: compressionType === 'lossless',
|
|
187
|
+
recursive: advancedOptions.includes('recursive'),
|
|
188
|
+
keepMetadata: advancedOptions.includes('keepMetadata'),
|
|
189
|
+
progressive: true,
|
|
190
|
+
jpegEngine,
|
|
191
|
+
pngEngine,
|
|
192
|
+
gifEngine
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
if (advancedOptions.includes('resize')) {
|
|
196
|
+
const resizeType = await p.select({
|
|
197
|
+
message: 'Resize method',
|
|
198
|
+
options: [
|
|
199
|
+
{ value: 'maxWidth', label: 'Maximum width', hint: 'Maintains aspect ratio' },
|
|
200
|
+
{ value: 'maxHeight', label: 'Maximum height', hint: 'Maintains aspect ratio' },
|
|
201
|
+
{ value: 'dimensions', label: 'Specific dimensions', hint: 'e.g., 800x600' },
|
|
202
|
+
{ value: 'percent', label: 'Percentage', hint: 'e.g., 50%' }
|
|
203
|
+
]
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
if (p.isCancel(resizeType)) {
|
|
207
|
+
p.cancel('Operation cancelled');
|
|
208
|
+
process.exit(0);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (resizeType === 'maxWidth') {
|
|
212
|
+
const maxWidth = await p.text({
|
|
213
|
+
message: 'Maximum width in pixels',
|
|
214
|
+
placeholder: '1920',
|
|
215
|
+
validate: (value) => {
|
|
216
|
+
const num = parseInt(value);
|
|
217
|
+
if (isNaN(num) || num < 1) {
|
|
218
|
+
return 'Please enter a valid number';
|
|
219
|
+
}
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (p.isCancel(maxWidth)) {
|
|
225
|
+
p.cancel('Operation cancelled');
|
|
226
|
+
process.exit(0);
|
|
227
|
+
}
|
|
228
|
+
options.maxWidth = maxWidth;
|
|
229
|
+
} else if (resizeType === 'maxHeight') {
|
|
230
|
+
const maxHeight = await p.text({
|
|
231
|
+
message: 'Maximum height in pixels',
|
|
232
|
+
placeholder: '1080',
|
|
233
|
+
validate: (value) => {
|
|
234
|
+
const num = parseInt(value);
|
|
235
|
+
if (isNaN(num) || num < 1) {
|
|
236
|
+
return 'Please enter a valid number';
|
|
237
|
+
}
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
if (p.isCancel(maxHeight)) {
|
|
243
|
+
p.cancel('Operation cancelled');
|
|
244
|
+
process.exit(0);
|
|
245
|
+
}
|
|
246
|
+
options.maxHeight = maxHeight;
|
|
247
|
+
} else if (resizeType === 'dimensions') {
|
|
248
|
+
const dimensions = await p.text({
|
|
249
|
+
message: 'Dimensions (WIDTHxHEIGHT)',
|
|
250
|
+
placeholder: '800x600',
|
|
251
|
+
validate: (value) => {
|
|
252
|
+
if (!/^\d+x\d+$/.test(value)) {
|
|
253
|
+
return 'Please use format: WIDTHxHEIGHT (e.g., 800x600)';
|
|
254
|
+
}
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
if (p.isCancel(dimensions)) {
|
|
260
|
+
p.cancel('Operation cancelled');
|
|
261
|
+
process.exit(0);
|
|
262
|
+
}
|
|
263
|
+
options.resize = dimensions;
|
|
264
|
+
} else if (resizeType === 'percent') {
|
|
265
|
+
const percent = await p.text({
|
|
266
|
+
message: 'Resize percentage',
|
|
267
|
+
placeholder: '50',
|
|
268
|
+
validate: (value) => {
|
|
269
|
+
const num = parseInt(value);
|
|
270
|
+
if (isNaN(num) || num < 1 || num > 100) {
|
|
271
|
+
return 'Please enter a number between 1 and 100';
|
|
272
|
+
}
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
if (p.isCancel(percent)) {
|
|
278
|
+
p.cancel('Operation cancelled');
|
|
279
|
+
process.exit(0);
|
|
280
|
+
}
|
|
281
|
+
options.resize = `${percent}%`;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const convertFormat = await p.confirm({
|
|
286
|
+
message: 'Convert images to a different format?',
|
|
287
|
+
initialValue: false
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
if (p.isCancel(convertFormat)) {
|
|
291
|
+
p.cancel('Operation cancelled');
|
|
292
|
+
process.exit(0);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (convertFormat) {
|
|
296
|
+
const format = await p.select({
|
|
297
|
+
message: 'Convert to format',
|
|
298
|
+
options: [
|
|
299
|
+
{ value: 'webp', label: 'WebP', hint: 'Modern format, great compression' },
|
|
300
|
+
{ value: 'avif', label: 'AVIF', hint: 'Best compression, newer format' },
|
|
301
|
+
{ value: 'jpg', label: 'JPEG', hint: 'Universal compatibility' },
|
|
302
|
+
{ value: 'png', label: 'PNG', hint: 'Lossless, supports transparency' }
|
|
303
|
+
]
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
if (p.isCancel(format)) {
|
|
307
|
+
p.cancel('Operation cancelled');
|
|
308
|
+
process.exit(0);
|
|
309
|
+
}
|
|
310
|
+
options.format = format as 'jpg' | 'png' | 'webp' | 'avif';
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
console.log('');
|
|
314
|
+
p.outro(pc.dim('Starting compression...'));
|
|
315
|
+
console.log('');
|
|
316
|
+
|
|
317
|
+
await compress(input, options);
|
|
318
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Command, Option } from 'commander';
|
|
2
|
+
import { createRequire } from 'module';
|
|
3
|
+
import { compress } from './commands/compress.js';
|
|
4
|
+
import { interactive } from './commands/interactive.js';
|
|
5
|
+
import { banner } from './utils/banner.js';
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const pkg = require('../package.json');
|
|
9
|
+
|
|
10
|
+
const program = new Command();
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name('qasai')
|
|
14
|
+
.description('Image compression CLI with lossless and lossy options')
|
|
15
|
+
.version(pkg.version, '-v, --version')
|
|
16
|
+
.helpOption('-h, --help', 'Display help information');
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.command('compress')
|
|
20
|
+
.description('Compress images in a directory')
|
|
21
|
+
.argument('[input]', 'Input directory or file', '.')
|
|
22
|
+
.option('-o, --output <dir>', 'Output directory (default: Kasai folder in input dir)')
|
|
23
|
+
.option('-i, --in-place', 'Compress images in place (overwrites originals)')
|
|
24
|
+
.option('-q, --quality <number>', 'Quality level 1-100 (default: 80)', '80')
|
|
25
|
+
.option('-l, --lossless', 'Use lossless compression')
|
|
26
|
+
.option('--resize <dimensions>', 'Resize images (e.g., 800x600, 50%)')
|
|
27
|
+
.option('--max-width <pixels>', 'Maximum width (maintains aspect ratio)')
|
|
28
|
+
.option('--max-height <pixels>', 'Maximum height (maintains aspect ratio)')
|
|
29
|
+
.option('-f, --format <format>', 'Convert to format (jpg, png, webp, avif)')
|
|
30
|
+
.option('-r, --recursive', 'Process subdirectories recursively')
|
|
31
|
+
.option('--keep-metadata', 'Preserve image metadata (EXIF, etc.)')
|
|
32
|
+
.option('--no-progressive', 'Disable progressive encoding for JPEGs')
|
|
33
|
+
.addOption(new Option('--effort <level>', 'Compression effort 1-10 (higher = slower but smaller)').default('6'))
|
|
34
|
+
.addOption(
|
|
35
|
+
new Option('--jpeg-engine <engine>', 'JPEG compression engine')
|
|
36
|
+
.choices(['mozjpeg', 'jpegtran', 'sharp'])
|
|
37
|
+
.default('mozjpeg')
|
|
38
|
+
)
|
|
39
|
+
.addOption(
|
|
40
|
+
new Option('--png-engine <engine>', 'PNG compression engine')
|
|
41
|
+
.choices(['pngquant', 'optipng', 'sharp'])
|
|
42
|
+
.default('pngquant')
|
|
43
|
+
)
|
|
44
|
+
.addOption(
|
|
45
|
+
new Option('--gif-engine <engine>', 'GIF compression engine')
|
|
46
|
+
.choices(['gifsicle', 'sharp'])
|
|
47
|
+
.default('gifsicle')
|
|
48
|
+
)
|
|
49
|
+
.option('--png-quality <range>', 'PNG quality range for pngquant (e.g., 65-80)', '65-80')
|
|
50
|
+
.option('--colors <number>', 'Max colors for PNG/GIF (2-256)', '256')
|
|
51
|
+
.action(compress);
|
|
52
|
+
|
|
53
|
+
program
|
|
54
|
+
.command('interactive', { isDefault: true })
|
|
55
|
+
.description('Run in interactive mode')
|
|
56
|
+
.action(async () => {
|
|
57
|
+
banner();
|
|
58
|
+
await interactive();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
program.parse();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
declare module 'mozjpeg' {
|
|
2
|
+
const mozjpeg: string;
|
|
3
|
+
export default mozjpeg;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
declare module 'jpegtran-bin' {
|
|
7
|
+
const jpegtran: string;
|
|
8
|
+
export default jpegtran;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
declare module 'pngquant-bin' {
|
|
12
|
+
const pngquant: string;
|
|
13
|
+
export default pngquant;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
declare module 'optipng-bin' {
|
|
17
|
+
const optipng: string;
|
|
18
|
+
export default optipng;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
declare module 'gifsicle' {
|
|
22
|
+
const gifsicle: string;
|
|
23
|
+
export default gifsicle;
|
|
24
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
|
|
3
|
+
export function banner() {
|
|
4
|
+
const terminalWidth = process.stdout.columns || 80;
|
|
5
|
+
const isSmall = terminalWidth < 60;
|
|
6
|
+
|
|
7
|
+
const bigArt = `
|
|
8
|
+
${pc.white(' ██████╗ █████╗ ███████╗ █████╗ ██╗')}
|
|
9
|
+
${pc.white(' ██╔═══██╗██╔══██╗██╔════╝██╔══██╗██║')}
|
|
10
|
+
${pc.gray(' ██║ ██║███████║███████╗███████║██║')}
|
|
11
|
+
${pc.gray(' ██║▄▄ ██║██╔══██║╚════██║██╔══██║██║')}
|
|
12
|
+
${pc.dim(' ╚██████╔╝██║ ██║███████║██║ ██║██║')}
|
|
13
|
+
${pc.dim(' ╚══▀▀═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝')}
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
const smallArt = `
|
|
17
|
+
${pc.white('█▀█▄▀██▀▄▀██')}
|
|
18
|
+
${pc.gray('▀▀██▀█▄██▀██')}
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
console.log(isSmall ? smallArt : bigArt);
|
|
22
|
+
console.log(pc.dim(' Image Compression CLI\n'));
|
|
23
|
+
}
|