image-convert-cli 1.0.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/README.md +206 -0
- package/bin/index.ts +30 -0
- package/dist/index.js +8554 -0
- package/index.ts +1 -0
- package/package.json +26 -0
- package/src/cli.ts +68 -0
- package/src/converter.ts +79 -0
- package/src/index.ts +10 -0
- package/src/prompts.ts +233 -0
- package/src/types.ts +27 -0
- package/src/utils/format.ts +7 -0
- package/src/utils/path.ts +10 -0
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
console.log("Hello via Bun!");
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "image-convert-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"imgc": "./dist/index.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "bun bin/index.ts",
|
|
10
|
+
"test": "bun test",
|
|
11
|
+
"prepare": "husky install",
|
|
12
|
+
"build": "bun build ./bin/index.ts --outdir ./dist --target bun",
|
|
13
|
+
"publish": "bun run build && bun publish --access public --ignore-scripts"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@inquirer/prompts": "^8.2.0",
|
|
20
|
+
"sharp": "^0.34.5"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/bun": "^1.3.8",
|
|
24
|
+
"husky": "^9.1.7"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ConvertOptions } from "./types";
|
|
2
|
+
import { IPromptService, InteractivePromptService } from "./prompts";
|
|
3
|
+
import { convertImage, displayConversionResult } from "./converter";
|
|
4
|
+
import { getDefaultDestinationPath } from "./utils/path";
|
|
5
|
+
|
|
6
|
+
export async function runCli(
|
|
7
|
+
options: ConvertOptions,
|
|
8
|
+
promptService?: IPromptService,
|
|
9
|
+
): Promise<void> {
|
|
10
|
+
const prompts = promptService || new InteractivePromptService();
|
|
11
|
+
|
|
12
|
+
if (options.help) {
|
|
13
|
+
prompts.showHelp();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (options.yes && (!options.source || !options.format)) {
|
|
18
|
+
console.error("Error: -y mode requires --source and --format arguments");
|
|
19
|
+
prompts.showHelp();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
console.log("Image Converter - Convert images to webp, jpeg, or jpg\n");
|
|
24
|
+
|
|
25
|
+
const sourcePath = options.source || await prompts.promptSourceFile();
|
|
26
|
+
|
|
27
|
+
const targetFormat =
|
|
28
|
+
options.format || (await prompts.promptFormat());
|
|
29
|
+
|
|
30
|
+
const destinationPath =
|
|
31
|
+
options.destination ||
|
|
32
|
+
(await prompts.promptDestination(
|
|
33
|
+
getDefaultDestinationPath(sourcePath, targetFormat),
|
|
34
|
+
));
|
|
35
|
+
|
|
36
|
+
const compress =
|
|
37
|
+
options.yes !== undefined ? options.yes : await prompts.promptCompress();
|
|
38
|
+
|
|
39
|
+
const settings = {
|
|
40
|
+
sourcePath,
|
|
41
|
+
targetFormat,
|
|
42
|
+
destinationPath,
|
|
43
|
+
compress,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
prompts.showSettings(settings);
|
|
47
|
+
|
|
48
|
+
const shouldProceed =
|
|
49
|
+
options.yes !== undefined ? options.yes : await prompts.promptConfirm(settings);
|
|
50
|
+
|
|
51
|
+
if (!shouldProceed) {
|
|
52
|
+
console.log("Conversion cancelled.");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const result = await convertImage(
|
|
57
|
+
sourcePath,
|
|
58
|
+
destinationPath,
|
|
59
|
+
targetFormat,
|
|
60
|
+
compress,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
displayConversionResult(result);
|
|
64
|
+
|
|
65
|
+
if (!result.success) {
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/converter.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import sharp from "sharp";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import type { ConversionResult, SupportedFormat } from "./types";
|
|
4
|
+
import { formatBytes } from "./utils/format";
|
|
5
|
+
|
|
6
|
+
export async function convertImage(
|
|
7
|
+
sourcePath: string,
|
|
8
|
+
destinationPath: string,
|
|
9
|
+
format: SupportedFormat,
|
|
10
|
+
compress: boolean,
|
|
11
|
+
): Promise<ConversionResult> {
|
|
12
|
+
const startTime = Date.now();
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const originalSize = fs.statSync(sourcePath).size;
|
|
16
|
+
|
|
17
|
+
// Determine output format for Sharp
|
|
18
|
+
let sharpFormat: keyof sharp.FormatEnum;
|
|
19
|
+
let options: sharp.JpegOptions | sharp.WebpOptions | sharp.OutputInfo;
|
|
20
|
+
|
|
21
|
+
switch (format) {
|
|
22
|
+
case "webp":
|
|
23
|
+
sharpFormat = "webp";
|
|
24
|
+
options = compress
|
|
25
|
+
? { quality: 100, lossless: true }
|
|
26
|
+
: { quality: 100 };
|
|
27
|
+
break;
|
|
28
|
+
case "jpeg":
|
|
29
|
+
case "jpg":
|
|
30
|
+
sharpFormat = "jpeg";
|
|
31
|
+
options = compress ? { quality: 100, mozjpeg: true } : { quality: 100 };
|
|
32
|
+
break;
|
|
33
|
+
default:
|
|
34
|
+
throw new Error(`Unsupported format: ${format}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Perform the conversion
|
|
38
|
+
await sharp(sourcePath)
|
|
39
|
+
.toFormat(sharpFormat, options)
|
|
40
|
+
.toFile(destinationPath);
|
|
41
|
+
|
|
42
|
+
const outputSize = fs.statSync(destinationPath).size;
|
|
43
|
+
const elapsed = Date.now() - startTime;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
success: true,
|
|
47
|
+
sourcePath,
|
|
48
|
+
destinationPath,
|
|
49
|
+
originalSize,
|
|
50
|
+
outputSize,
|
|
51
|
+
elapsed,
|
|
52
|
+
};
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return {
|
|
55
|
+
success: false,
|
|
56
|
+
sourcePath,
|
|
57
|
+
destinationPath,
|
|
58
|
+
originalSize: 0,
|
|
59
|
+
outputSize: 0,
|
|
60
|
+
elapsed: Date.now() - startTime,
|
|
61
|
+
error: (error as Error).message,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function displayConversionResult(result: ConversionResult): void {
|
|
67
|
+
if (result.success) {
|
|
68
|
+
console.log("\n✓ Conversion complete!");
|
|
69
|
+
console.log(` Time: ${result.elapsed}ms`);
|
|
70
|
+
console.log(` Original size: ${formatBytes(result.originalSize)}`);
|
|
71
|
+
console.log(` Output size: ${formatBytes(result.outputSize)}`);
|
|
72
|
+
console.log(
|
|
73
|
+
` Saved: ${(((result.originalSize - result.outputSize) / result.originalSize) * 100).toFixed(1)}%`,
|
|
74
|
+
);
|
|
75
|
+
console.log(`\nOutput saved to: ${result.destinationPath}`);
|
|
76
|
+
} else {
|
|
77
|
+
console.error("\n✗ Conversion failed:", result.error);
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from "./types";
|
|
2
|
+
export * from "./utils/path";
|
|
3
|
+
export * from "./utils/format";
|
|
4
|
+
export { convertImage, displayConversionResult } from "./converter";
|
|
5
|
+
export { runCli } from "./cli";
|
|
6
|
+
export {
|
|
7
|
+
type IPromptService,
|
|
8
|
+
InteractivePromptService,
|
|
9
|
+
NoopPromptService,
|
|
10
|
+
} from "./prompts";
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import * as readline from "node:readline";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { select, confirm } from "@inquirer/prompts";
|
|
5
|
+
import type { SupportedFormat, ConversionSettings } from "./types";
|
|
6
|
+
import { getDefaultDestinationPath } from "./utils/path";
|
|
7
|
+
import { displayConversionResult } from "./converter";
|
|
8
|
+
|
|
9
|
+
function filePathCompleter(line: string): readline.CompleterResult {
|
|
10
|
+
const trimmed = line.trim();
|
|
11
|
+
const input = trimmed.split(" ")[0] || ".";
|
|
12
|
+
|
|
13
|
+
const isDirectoryInput = input.endsWith("/");
|
|
14
|
+
const dir = isDirectoryInput ? input.slice(0, -1) || "." : path.dirname(input) || ".";
|
|
15
|
+
const base = isDirectoryInput ? "" : (path.basename(input) || "");
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const files: fs.Dirent[] = fs.readdirSync(dir, { withFileTypes: true });
|
|
19
|
+
|
|
20
|
+
const filtered = base
|
|
21
|
+
? files.filter((dirent) => dirent.name.startsWith(base))
|
|
22
|
+
: files;
|
|
23
|
+
|
|
24
|
+
const completions = filtered.map((dirent) => {
|
|
25
|
+
const fullPath = dir === "." ? dirent.name : `${dir}/${dirent.name}`;
|
|
26
|
+
return dirent.isDirectory() ? `${fullPath}/` : fullPath;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
return [completions.length ? completions : [input], input];
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return [[input], input];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function inputWithPathCompletion(
|
|
36
|
+
message: string,
|
|
37
|
+
defaultValue?: string,
|
|
38
|
+
validate?: (input: string) => string | true,
|
|
39
|
+
): Promise<string> {
|
|
40
|
+
const rl = readline.createInterface({
|
|
41
|
+
input: process.stdin,
|
|
42
|
+
output: process.stdout,
|
|
43
|
+
completer: filePathCompleter,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const question = defaultValue
|
|
47
|
+
? `${message} (${defaultValue}): `
|
|
48
|
+
: `${message}: `;
|
|
49
|
+
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
rl.question(question, (answer) => {
|
|
52
|
+
const result = answer.trim() || defaultValue || "";
|
|
53
|
+
rl.close();
|
|
54
|
+
|
|
55
|
+
if (validate) {
|
|
56
|
+
const validationResult = validate(result);
|
|
57
|
+
if (validationResult !== true) {
|
|
58
|
+
console.log(`\nError: ${validationResult}`);
|
|
59
|
+
inputWithPathCompletion(message, defaultValue, validate).then(resolve);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
resolve(result);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface IPromptService {
|
|
70
|
+
promptSourceFile(validate?: (path: string) => string | true): Promise<string>;
|
|
71
|
+
promptDestination(defaultPath: string, validate?: (path: string) => string | true): Promise<string>;
|
|
72
|
+
promptFormat(): Promise<SupportedFormat>;
|
|
73
|
+
promptCompress(): Promise<boolean>;
|
|
74
|
+
promptConfirm(settings: ConversionSettings): Promise<boolean>;
|
|
75
|
+
showHelp(): void;
|
|
76
|
+
showSettings(settings: ConversionSettings): void;
|
|
77
|
+
showResult(result: { success: boolean; destinationPath: string; elapsed: number; originalSize: number; outputSize: number; error?: string }): void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class InteractivePromptService implements IPromptService {
|
|
81
|
+
async promptSourceFile(
|
|
82
|
+
validate?: (path: string) => string | true,
|
|
83
|
+
): Promise<string> {
|
|
84
|
+
return inputWithPathCompletion(
|
|
85
|
+
"Source file path",
|
|
86
|
+
undefined,
|
|
87
|
+
validate ||
|
|
88
|
+
((input) => {
|
|
89
|
+
if (!input.trim()) {
|
|
90
|
+
return "Please enter a file path";
|
|
91
|
+
}
|
|
92
|
+
if (!fs.existsSync(input)) {
|
|
93
|
+
return "File does not exist";
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async promptDestination(
|
|
101
|
+
defaultPath: string,
|
|
102
|
+
validate?: (path: string) => string | true,
|
|
103
|
+
): Promise<string> {
|
|
104
|
+
return inputWithPathCompletion(
|
|
105
|
+
"Destination path",
|
|
106
|
+
defaultPath,
|
|
107
|
+
validate ||
|
|
108
|
+
((input) => {
|
|
109
|
+
if (!input.trim()) {
|
|
110
|
+
return "Please enter a destination path";
|
|
111
|
+
}
|
|
112
|
+
const dir = path.dirname(input);
|
|
113
|
+
if (dir !== "." && !fs.existsSync(dir)) {
|
|
114
|
+
return "Destination directory does not exist";
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async promptFormat(): Promise<SupportedFormat> {
|
|
122
|
+
return select<SupportedFormat>({
|
|
123
|
+
message: "Target format:",
|
|
124
|
+
choices: [
|
|
125
|
+
{ value: "webp", description: "WebP format (recommended for web)" },
|
|
126
|
+
{ value: "jpeg", description: "JPEG format" },
|
|
127
|
+
{ value: "jpg", description: "JPG format" },
|
|
128
|
+
],
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async promptCompress(): Promise<boolean> {
|
|
133
|
+
return confirm({
|
|
134
|
+
message: "Do you want to compress the image?",
|
|
135
|
+
default: false,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async promptConfirm(settings: ConversionSettings): Promise<boolean> {
|
|
140
|
+
return confirm({
|
|
141
|
+
message: "Proceed with conversion?",
|
|
142
|
+
default: true,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
showHelp(): void {
|
|
147
|
+
console.log(`Image Converter CLI
|
|
148
|
+
|
|
149
|
+
Usage:
|
|
150
|
+
imgc [options]
|
|
151
|
+
imgc --source <path> --format <format> [options]
|
|
152
|
+
imgc -y --source <path> --format <format>
|
|
153
|
+
|
|
154
|
+
Options:
|
|
155
|
+
--help, -h Show this help message
|
|
156
|
+
--yes, -y Non-interactive mode (use defaults for optional prompts)
|
|
157
|
+
--source, -s Source file path (required with -y)
|
|
158
|
+
--format, -f Target format: webp, jpeg, or jpg (required with -y)
|
|
159
|
+
--dest, -d Destination path (optional, auto-generated if not provided)
|
|
160
|
+
--compress, -c Enable compression (optional, default: false)
|
|
161
|
+
|
|
162
|
+
Examples:
|
|
163
|
+
imgc # Interactive mode
|
|
164
|
+
imgc --help # Show help
|
|
165
|
+
imgc -y --source photo.png --format webp
|
|
166
|
+
imgc -y --source photo.jpg --format jpeg --compress
|
|
167
|
+
`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
showSettings(settings: ConversionSettings): void {
|
|
171
|
+
console.log("\nConversion settings:");
|
|
172
|
+
console.log(` Source: ${settings.sourcePath}`);
|
|
173
|
+
console.log(` Target format: ${settings.targetFormat.toUpperCase()}`);
|
|
174
|
+
console.log(` Destination: ${settings.destinationPath}`);
|
|
175
|
+
console.log(` Compression: ${settings.compress ? "Yes" : "No"}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
showResult(result: { success: boolean; destinationPath: string; elapsed: number; originalSize: number; outputSize: number; error?: string }): void {
|
|
179
|
+
displayConversionResult({
|
|
180
|
+
success: result.success,
|
|
181
|
+
sourcePath: "",
|
|
182
|
+
destinationPath: result.destinationPath,
|
|
183
|
+
originalSize: result.originalSize,
|
|
184
|
+
outputSize: result.outputSize,
|
|
185
|
+
elapsed: result.elapsed,
|
|
186
|
+
error: result.error,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export class NoopPromptService implements IPromptService {
|
|
192
|
+
private responses: {
|
|
193
|
+
source?: string;
|
|
194
|
+
destination?: string;
|
|
195
|
+
format?: SupportedFormat;
|
|
196
|
+
compress?: boolean;
|
|
197
|
+
confirm?: boolean;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
constructor(responses?: {
|
|
201
|
+
source?: string;
|
|
202
|
+
destination?: string;
|
|
203
|
+
format?: SupportedFormat;
|
|
204
|
+
compress?: boolean;
|
|
205
|
+
confirm?: boolean;
|
|
206
|
+
}) {
|
|
207
|
+
this.responses = responses || {};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async promptSourceFile(): Promise<string> {
|
|
211
|
+
return this.responses.source || "";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async promptDestination(defaultPath: string): Promise<string> {
|
|
215
|
+
return this.responses.destination || defaultPath;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async promptFormat(): Promise<SupportedFormat> {
|
|
219
|
+
return this.responses.format || "webp";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async promptCompress(): Promise<boolean> {
|
|
223
|
+
return this.responses.compress ?? false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async promptConfirm(): Promise<boolean> {
|
|
227
|
+
return this.responses.confirm ?? true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
showHelp(): void {}
|
|
231
|
+
showSettings(): void {}
|
|
232
|
+
showResult(): void {}
|
|
233
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type SupportedFormat = "webp" | "jpeg" | "jpg";
|
|
2
|
+
|
|
3
|
+
export type ConvertOptions = {
|
|
4
|
+
help?: boolean;
|
|
5
|
+
yes?: boolean;
|
|
6
|
+
source?: string;
|
|
7
|
+
format?: SupportedFormat;
|
|
8
|
+
destination?: string;
|
|
9
|
+
compress?: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type ConversionResult = {
|
|
13
|
+
success: boolean;
|
|
14
|
+
sourcePath: string;
|
|
15
|
+
destinationPath: string;
|
|
16
|
+
originalSize: number;
|
|
17
|
+
outputSize: number;
|
|
18
|
+
elapsed: number;
|
|
19
|
+
error?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type ConversionSettings = {
|
|
23
|
+
sourcePath: string;
|
|
24
|
+
targetFormat: SupportedFormat;
|
|
25
|
+
destinationPath: string;
|
|
26
|
+
compress: boolean;
|
|
27
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function formatBytes(bytes: number): string {
|
|
2
|
+
if (bytes === 0) return "0 B";
|
|
3
|
+
const k = 1024;
|
|
4
|
+
const sizes = ["B", "KB", "MB", "GB"];
|
|
5
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
6
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
|
7
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type { SupportedFormat } from "../types";
|
|
3
|
+
|
|
4
|
+
export function getDefaultDestinationPath(
|
|
5
|
+
sourcePath: string,
|
|
6
|
+
format: SupportedFormat,
|
|
7
|
+
): string {
|
|
8
|
+
const parsed = path.parse(sourcePath);
|
|
9
|
+
return path.join(parsed.dir, `${parsed.name}.${format}`);
|
|
10
|
+
}
|