@lagandevs/cvt 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/README.md +56 -0
- package/dist/cli.js +331 -0
- package/dist/image-engine.js +50 -0
- package/dist/tui.js +231 -0
- package/package.json +50 -0
- package/scripts/make-executable.mjs +5 -0
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# cvt
|
|
2
|
+
|
|
3
|
+
`cvt` converts image files and images copied to the clipboard on macOS, Windows, and Linux. It can make a GIF from one image or combine several images into an animation.
|
|
4
|
+
|
|
5
|
+
## Run without installing
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npx @lagandevs/cvt
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`npx` downloads the package into its cache and opens the interactive interface. It does not add `cvt` globally. To run a one-line conversion through `npx`, put the CLI arguments after the package name:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npx @lagandevs/cvt photo.png -o photo.gif
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Run `cvt` with no arguments to open the interactive terminal interface. Use the keyboard or mouse to choose a source and format, enter an output path, then convert without leaving the interface. You can drag a file from Finder into the Files field.
|
|
18
|
+
|
|
19
|
+
After each conversion, `cvt` puts the converted file on the desktop clipboard. Paste it into Finder, Explorer, your Linux file manager, or another app. Pass `--no-copy` when using the one-line command if you only want the file saved to disk.
|
|
20
|
+
|
|
21
|
+
## Examples
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
# Image to GIF
|
|
25
|
+
cvt photo.png -o photo.gif
|
|
26
|
+
|
|
27
|
+
# Several images to an animated GIF
|
|
28
|
+
cvt frame-1.png frame-2.png frame-3.png -o animation.gif --delay 12
|
|
29
|
+
|
|
30
|
+
# Copy an image, then read it straight from the clipboard
|
|
31
|
+
cvt --paste -o copied-image.gif
|
|
32
|
+
|
|
33
|
+
# PNG, JPEG, WebP, HEIC, TIFF, and BMP conversions
|
|
34
|
+
cvt photo.heic -o photo.jpg --quality 88
|
|
35
|
+
cvt photo.png -o photo.webp --resize '1200x1200>'
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Run `cvt --help` for every option. If you omit the input file, `cvt` reads the clipboard automatically. If you omit the output, it creates a GIF beside the source file, or `clipboard.gif` in the current directory.
|
|
39
|
+
|
|
40
|
+
## Development
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
pnpm install
|
|
44
|
+
pnpm check
|
|
45
|
+
pnpm build
|
|
46
|
+
pnpm install --global .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Requirements
|
|
50
|
+
|
|
51
|
+
- Node.js 20 or newer
|
|
52
|
+
- Linux clipboard access: `wl-clipboard` on Wayland or `xclip` on X11
|
|
53
|
+
|
|
54
|
+
`cvt` includes [ImageMagick](https://imagemagick.org/) through the `@imagemagick/magick-wasm` WebAssembly build. Users do not need to install ImageMagick. If a system ImageMagick binary exists, `cvt` can use it as a fallback for HEIC encoding, which the smaller WebAssembly build omits.
|
|
55
|
+
|
|
56
|
+
Use Windows Terminal, iTerm2, Terminal.app, or a modern Linux terminal for mouse support in the interactive interface.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command, Option } from "commander";
|
|
3
|
+
import { execFile, spawn } from "node:child_process";
|
|
4
|
+
import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { extname, join, resolve } from "node:path";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
|
+
import { runTui } from "./tui.js";
|
|
10
|
+
import { convertWithBuiltInImageMagick } from "./image-engine.js";
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
const formats = ["gif", "png", "jpg", "jpeg", "webp", "heic", "tiff", "bmp"];
|
|
13
|
+
function fail(message) {
|
|
14
|
+
process.stderr.write(`cvt: ${message}\n`);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
async function commandExists(command) {
|
|
18
|
+
try {
|
|
19
|
+
await execFileAsync(process.platform === "win32" ? "where.exe" : "/usr/bin/which", [command]);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function capture(command, args, input) {
|
|
27
|
+
return new Promise((done, reject) => {
|
|
28
|
+
const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
29
|
+
const stdout = [];
|
|
30
|
+
const stderr = [];
|
|
31
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
32
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
33
|
+
child.once("error", reject);
|
|
34
|
+
child.once("exit", (code) => code === 0
|
|
35
|
+
? done(Buffer.concat(stdout))
|
|
36
|
+
: reject(new Error(Buffer.concat(stderr).toString().trim() || `${command} exited with code ${code}`)));
|
|
37
|
+
child.stdin.end(input);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function lastErrorLine(error, fallback) {
|
|
41
|
+
if (!(error instanceof Error))
|
|
42
|
+
return String(error) || fallback;
|
|
43
|
+
return error.message.split("\n").map((line) => line.trim()).filter(Boolean).at(-1) ?? fallback;
|
|
44
|
+
}
|
|
45
|
+
async function readClipboardImage(destination) {
|
|
46
|
+
if (process.platform === "win32")
|
|
47
|
+
return readWindowsClipboard(destination);
|
|
48
|
+
if (process.platform === "linux")
|
|
49
|
+
return readLinuxClipboard(destination);
|
|
50
|
+
try {
|
|
51
|
+
const { stdout } = await execFileAsync("/usr/bin/osascript", [
|
|
52
|
+
"-l", "JavaScript", "-e",
|
|
53
|
+
'ObjC.import("AppKit"); ObjC.unwrap($.NSPasteboard.generalPasteboard.stringForType("public.file-url"))',
|
|
54
|
+
]);
|
|
55
|
+
const clipboardFile = fileURLToPath(stdout.trim());
|
|
56
|
+
if (clipboardFile && (await stat(clipboardFile)).isFile())
|
|
57
|
+
return clipboardFile;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// The clipboard may contain image pixels instead of a file reference.
|
|
61
|
+
}
|
|
62
|
+
const script = `
|
|
63
|
+
on run argv
|
|
64
|
+
try
|
|
65
|
+
set imageData to the clipboard as «class PNGf»
|
|
66
|
+
on error
|
|
67
|
+
try
|
|
68
|
+
set imageData to the clipboard as TIFF picture
|
|
69
|
+
on error
|
|
70
|
+
error "The clipboard does not contain an image or image file. Copy one, then try again."
|
|
71
|
+
end try
|
|
72
|
+
end try
|
|
73
|
+
set outputFile to open for access POSIX file (item 1 of argv) with write permission
|
|
74
|
+
try
|
|
75
|
+
set eof outputFile to 0
|
|
76
|
+
write imageData to outputFile
|
|
77
|
+
on error errorMessage
|
|
78
|
+
close access outputFile
|
|
79
|
+
error errorMessage
|
|
80
|
+
end try
|
|
81
|
+
close access outputFile
|
|
82
|
+
end run
|
|
83
|
+
`;
|
|
84
|
+
try {
|
|
85
|
+
await execFileAsync("/usr/bin/osascript", ["-e", script, destination]);
|
|
86
|
+
return destination;
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
fail(lastErrorLine(error, "could not read an image from the clipboard"));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function readWindowsClipboard(destination) {
|
|
93
|
+
const script = `
|
|
94
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
95
|
+
Add-Type -AssemblyName System.Drawing
|
|
96
|
+
$files = [System.Windows.Forms.Clipboard]::GetFileDropList()
|
|
97
|
+
if ($files.Count -gt 0) { Write-Output $files[0]; exit 0 }
|
|
98
|
+
if ([System.Windows.Forms.Clipboard]::ContainsImage()) {
|
|
99
|
+
$image = [System.Windows.Forms.Clipboard]::GetImage()
|
|
100
|
+
$image.Save($env:CVT_CLIPBOARD_DEST, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
101
|
+
Write-Output $env:CVT_CLIPBOARD_DEST
|
|
102
|
+
exit 0
|
|
103
|
+
}
|
|
104
|
+
Write-Error "The clipboard does not contain an image or image file."
|
|
105
|
+
exit 2
|
|
106
|
+
`;
|
|
107
|
+
try {
|
|
108
|
+
const { stdout } = await execFileAsync("powershell.exe", ["-NoProfile", "-STA", "-Command", script], {
|
|
109
|
+
env: { ...process.env, CVT_CLIPBOARD_DEST: destination },
|
|
110
|
+
});
|
|
111
|
+
return stdout.trim();
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
fail(lastErrorLine(error, "could not read an image from the Windows clipboard"));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function readLinuxClipboard(destination) {
|
|
118
|
+
const wayland = await commandExists("wl-paste");
|
|
119
|
+
const x11 = await commandExists("xclip");
|
|
120
|
+
if (!wayland && !x11)
|
|
121
|
+
fail("clipboard support needs wl-clipboard on Wayland or xclip on X11");
|
|
122
|
+
try {
|
|
123
|
+
const listArgs = wayland ? ["--list-types"] : ["-selection", "clipboard", "-t", "TARGETS", "-o"];
|
|
124
|
+
const types = (await capture(wayland ? "wl-paste" : "xclip", listArgs)).toString();
|
|
125
|
+
if (types.includes("text/uri-list")) {
|
|
126
|
+
const uriArgs = wayland
|
|
127
|
+
? ["--no-newline", "--type", "text/uri-list"]
|
|
128
|
+
: ["-selection", "clipboard", "-t", "text/uri-list", "-o"];
|
|
129
|
+
const uriList = (await capture(wayland ? "wl-paste" : "xclip", uriArgs)).toString();
|
|
130
|
+
const first = uriList.split(/\r?\n/).find((line) => line.startsWith("file://"));
|
|
131
|
+
if (first)
|
|
132
|
+
return fileURLToPath(first.trim());
|
|
133
|
+
}
|
|
134
|
+
const mime = ["image/png", "image/jpeg", "image/webp", "image/tiff", "image/bmp"]
|
|
135
|
+
.find((candidate) => types.includes(candidate));
|
|
136
|
+
if (!mime)
|
|
137
|
+
fail("the clipboard does not contain an image or image file");
|
|
138
|
+
const imageArgs = wayland
|
|
139
|
+
? ["--type", mime]
|
|
140
|
+
: ["-selection", "clipboard", "-t", mime, "-o"];
|
|
141
|
+
await writeFile(destination, await capture(wayland ? "wl-paste" : "xclip", imageArgs));
|
|
142
|
+
return destination;
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
fail(lastErrorLine(error, "could not read an image from the Linux clipboard"));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async function copyFileToClipboard(file) {
|
|
149
|
+
if (process.platform === "win32") {
|
|
150
|
+
const script = `
|
|
151
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
152
|
+
$files = New-Object System.Collections.Specialized.StringCollection
|
|
153
|
+
[void]$files.Add($env:CVT_CLIPBOARD_FILE)
|
|
154
|
+
[System.Windows.Forms.Clipboard]::SetFileDropList($files)
|
|
155
|
+
`;
|
|
156
|
+
try {
|
|
157
|
+
await execFileAsync("powershell.exe", ["-NoProfile", "-STA", "-Command", script], {
|
|
158
|
+
env: { ...process.env, CVT_CLIPBOARD_FILE: file },
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
fail(lastErrorLine(error, "could not copy the converted file to the Windows clipboard"));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (process.platform === "linux") {
|
|
167
|
+
const uri = `${pathToFileURL(file).href}\n`;
|
|
168
|
+
try {
|
|
169
|
+
if (await commandExists("wl-copy"))
|
|
170
|
+
await capture("wl-copy", ["--type", "text/uri-list"], uri);
|
|
171
|
+
else if (await commandExists("xclip"))
|
|
172
|
+
await capture("xclip", ["-selection", "clipboard", "-t", "text/uri-list", "-i"], uri);
|
|
173
|
+
else
|
|
174
|
+
fail("clipboard support needs wl-clipboard on Wayland or xclip on X11");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
fail(lastErrorLine(error, "could not copy the converted file to the Linux clipboard"));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const script = `
|
|
182
|
+
on run argv
|
|
183
|
+
set the clipboard to POSIX file (item 1 of argv)
|
|
184
|
+
end run
|
|
185
|
+
`;
|
|
186
|
+
try {
|
|
187
|
+
await execFileAsync("/usr/bin/osascript", ["-e", script, file]);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
fail(lastErrorLine(error, "could not copy the converted file to the clipboard"));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function inferredOutput(input, format) {
|
|
194
|
+
if (!input)
|
|
195
|
+
return resolve(`clipboard.${format === "jpeg" ? "jpg" : format}`);
|
|
196
|
+
const extension = extname(input);
|
|
197
|
+
const stem = extension ? input.slice(0, -extension.length) : input;
|
|
198
|
+
return resolve(`${stem}.${format === "jpeg" ? "jpg" : format}`);
|
|
199
|
+
}
|
|
200
|
+
function parsePositiveInteger(value, label, allowZero = false) {
|
|
201
|
+
const parsed = Number(value);
|
|
202
|
+
if (!Number.isInteger(parsed) || parsed < (allowZero ? 0 : 1))
|
|
203
|
+
fail(`${label} must be ${allowZero ? "zero or " : ""}a positive integer`);
|
|
204
|
+
return parsed;
|
|
205
|
+
}
|
|
206
|
+
async function runMagick(args) {
|
|
207
|
+
await new Promise((resolvePromise, reject) => {
|
|
208
|
+
const child = spawn("magick", args, { stdio: "inherit" });
|
|
209
|
+
child.once("error", reject);
|
|
210
|
+
child.once("exit", (code, signal) => {
|
|
211
|
+
if (code === 0)
|
|
212
|
+
resolvePromise();
|
|
213
|
+
else
|
|
214
|
+
reject(new Error(signal ? `ImageMagick stopped by ${signal}` : `ImageMagick exited with code ${code}`));
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
async function convertImages(inputs, options) {
|
|
219
|
+
if (options.paste && inputs.length)
|
|
220
|
+
fail("use either file inputs or --paste, not both");
|
|
221
|
+
const useClipboard = options.paste || inputs.length === 0;
|
|
222
|
+
let tempDirectory;
|
|
223
|
+
let sourceInputs = inputs.map((input) => resolve(input));
|
|
224
|
+
try {
|
|
225
|
+
if (useClipboard) {
|
|
226
|
+
tempDirectory = await mkdtemp(join(tmpdir(), "cvt-"));
|
|
227
|
+
const clipboardPath = join(tempDirectory, "clipboard-image");
|
|
228
|
+
sourceInputs = [await readClipboardImage(clipboardPath)];
|
|
229
|
+
}
|
|
230
|
+
for (const input of sourceInputs) {
|
|
231
|
+
try {
|
|
232
|
+
if (!(await stat(input)).isFile())
|
|
233
|
+
fail(`not a file: ${input}`);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
fail(`file not found: ${input}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const requestedFormat = options.format ?? (options.output ? extname(options.output).slice(1).toLowerCase() : "gif");
|
|
240
|
+
if (!formats.includes(requestedFormat))
|
|
241
|
+
fail(`unsupported output format: ${requestedFormat || "none"}`);
|
|
242
|
+
const format = requestedFormat;
|
|
243
|
+
const output = resolve(options.output ?? inferredOutput(useClipboard ? undefined : inputs[0], format));
|
|
244
|
+
if (!options.overwrite) {
|
|
245
|
+
try {
|
|
246
|
+
await stat(output);
|
|
247
|
+
fail(`output already exists: ${output} (pass --overwrite to replace it)`);
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
if (error instanceof Error && error.message.startsWith("cvt:"))
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
await convertWithBuiltInImageMagick({
|
|
256
|
+
inputs: sourceInputs,
|
|
257
|
+
output,
|
|
258
|
+
format,
|
|
259
|
+
delay: parsePositiveInteger(options.delay, "delay"),
|
|
260
|
+
loop: parsePositiveInteger(options.loop, "loop", true),
|
|
261
|
+
resize: options.resize,
|
|
262
|
+
quality: options.quality ? parsePositiveInteger(options.quality, "quality") : undefined,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
if (await commandExists("magick")) {
|
|
267
|
+
const args = [...sourceInputs];
|
|
268
|
+
if (options.resize)
|
|
269
|
+
args.push("-resize", options.resize);
|
|
270
|
+
if (options.quality)
|
|
271
|
+
args.push("-quality", options.quality);
|
|
272
|
+
if (format === "gif")
|
|
273
|
+
args.push("-delay", options.delay, "-loop", options.loop, "-layers", "Optimize");
|
|
274
|
+
args.push(`${format}:${output}`);
|
|
275
|
+
try {
|
|
276
|
+
await runMagick(args);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (options.copy !== false) {
|
|
287
|
+
await copyFileToClipboard(output);
|
|
288
|
+
process.stdout.write(`Created ${output} and copied it to the clipboard\n`);
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
process.stdout.write(`Created ${output}\n`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
if (tempDirectory)
|
|
296
|
+
await rm(tempDirectory, { recursive: true, force: true });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const program = new Command()
|
|
300
|
+
.name("cvt")
|
|
301
|
+
.description("Convert images from files or the desktop clipboard")
|
|
302
|
+
.version("0.1.0")
|
|
303
|
+
.showHelpAfterError();
|
|
304
|
+
program
|
|
305
|
+
.command("image", { isDefault: true })
|
|
306
|
+
.description("Convert one image, or combine several images into an animated GIF")
|
|
307
|
+
.argument("[inputs...]", "input image files in frame order")
|
|
308
|
+
.option("-p, --paste", "read an image directly from the desktop clipboard")
|
|
309
|
+
.option("-o, --output <file>", "output path")
|
|
310
|
+
.addOption(new Option("-f, --format <format>", "output format when no extension is given").choices([...formats]))
|
|
311
|
+
.option("--delay <centiseconds>", "GIF frame delay in hundredths of a second", "10")
|
|
312
|
+
.option("--loop <count>", "GIF loop count; 0 loops forever", "0")
|
|
313
|
+
.option("-r, --resize <geometry>", "resize, such as 800x600 or 50%")
|
|
314
|
+
.option("-q, --quality <number>", "output quality")
|
|
315
|
+
.option("-y, --overwrite", "replace an existing output file")
|
|
316
|
+
.option("--no-copy", "do not copy the converted file to the clipboard")
|
|
317
|
+
.addHelpText("after", `
|
|
318
|
+
Examples:
|
|
319
|
+
cvt photo.png -o photo.gif
|
|
320
|
+
cvt frame-1.png frame-2.png -o animation.gif --delay 15
|
|
321
|
+
cvt --paste -o clipboard.gif
|
|
322
|
+
cvt photo.heic -o photo.jpg -q 88
|
|
323
|
+
cvt photo.png -f webp -r 1200x1200\>
|
|
324
|
+
`)
|
|
325
|
+
.action(convertImages);
|
|
326
|
+
if (process.argv.length === 2) {
|
|
327
|
+
runTui().catch((error) => fail(error instanceof Error ? error.message : String(error)));
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
program.parseAsync().catch((error) => fail(error instanceof Error ? error.message : String(error)));
|
|
331
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { MagickFormat, MagickGeometry, MagickImageCollection, initializeImageMagick, } from "@imagemagick/magick-wasm";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
let initialization;
|
|
4
|
+
function initialize() {
|
|
5
|
+
if (!initialization) {
|
|
6
|
+
initialization = readFile(new URL(import.meta.resolve("@imagemagick/magick-wasm/magick.wasm")))
|
|
7
|
+
.then((wasm) => initializeImageMagick(wasm));
|
|
8
|
+
}
|
|
9
|
+
return initialization;
|
|
10
|
+
}
|
|
11
|
+
const outputFormats = {
|
|
12
|
+
gif: MagickFormat.Gif,
|
|
13
|
+
png: MagickFormat.Png,
|
|
14
|
+
jpg: MagickFormat.Jpeg,
|
|
15
|
+
jpeg: MagickFormat.Jpeg,
|
|
16
|
+
webp: MagickFormat.WebP,
|
|
17
|
+
heic: MagickFormat.Heic,
|
|
18
|
+
tiff: MagickFormat.Tiff,
|
|
19
|
+
bmp: MagickFormat.Bmp,
|
|
20
|
+
};
|
|
21
|
+
export async function convertWithBuiltInImageMagick(options) {
|
|
22
|
+
await initialize();
|
|
23
|
+
const images = MagickImageCollection.create();
|
|
24
|
+
try {
|
|
25
|
+
for (const input of options.inputs) {
|
|
26
|
+
const inputImages = MagickImageCollection.create(await readFile(input));
|
|
27
|
+
images.push(...inputImages.splice(0));
|
|
28
|
+
inputImages.dispose();
|
|
29
|
+
}
|
|
30
|
+
if (images.length === 0)
|
|
31
|
+
throw new Error("ImageMagick could not read the input image");
|
|
32
|
+
const geometry = options.resize ? new MagickGeometry(options.resize) : undefined;
|
|
33
|
+
for (const image of images) {
|
|
34
|
+
if (geometry)
|
|
35
|
+
image.resize(geometry);
|
|
36
|
+
if (options.quality)
|
|
37
|
+
image.quality = options.quality;
|
|
38
|
+
if (options.format === "gif") {
|
|
39
|
+
image.animationDelay = options.delay;
|
|
40
|
+
image.animationIterations = options.loop;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (options.format === "gif" && images.length > 1)
|
|
44
|
+
images.optimize();
|
|
45
|
+
await images.write(outputFormats[options.format], (data) => writeFile(options.output, data));
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
images.dispose();
|
|
49
|
+
}
|
|
50
|
+
}
|
package/dist/tui.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { extname, resolve } from "node:path";
|
|
3
|
+
import { emitKeypressEvents } from "node:readline";
|
|
4
|
+
const ESC = "\u001b[";
|
|
5
|
+
const formats = ["gif", "png", "jpg", "webp", "heic", "tiff", "bmp"];
|
|
6
|
+
const fields = ["source", "files", "format", "output", "delay", "resize", "convert"];
|
|
7
|
+
const hitRows = new Map();
|
|
8
|
+
function stripDropEscapes(value) {
|
|
9
|
+
return value.trim().replace(/^['"]|['"]$/g, "").replace(/\\([ '\\()])/g, "$1");
|
|
10
|
+
}
|
|
11
|
+
function parseFiles(value) {
|
|
12
|
+
return value.split("|").map(stripDropEscapes).filter(Boolean);
|
|
13
|
+
}
|
|
14
|
+
function defaultOutput(state) {
|
|
15
|
+
if (state.source === "clipboard" || !state.files.trim())
|
|
16
|
+
return `clipboard.${state.format}`;
|
|
17
|
+
const first = parseFiles(state.files)[0] ?? "converted";
|
|
18
|
+
const extension = extname(first);
|
|
19
|
+
return `${extension ? first.slice(0, -extension.length) : first}.${state.format}`;
|
|
20
|
+
}
|
|
21
|
+
function pad(value, width) {
|
|
22
|
+
return value.length > width ? `${value.slice(0, Math.max(1, width - 1))}…` : value.padEnd(width);
|
|
23
|
+
}
|
|
24
|
+
function runConversion(state) {
|
|
25
|
+
const args = [process.argv[1], "image"];
|
|
26
|
+
if (state.source === "clipboard")
|
|
27
|
+
args.push("--paste");
|
|
28
|
+
else
|
|
29
|
+
args.push(...parseFiles(state.files));
|
|
30
|
+
args.push("--output", resolve(state.output || defaultOutput(state)), "--format", state.format, "--overwrite");
|
|
31
|
+
if (state.format === "gif")
|
|
32
|
+
args.push("--delay", state.delay || "10");
|
|
33
|
+
if (state.resize.trim())
|
|
34
|
+
args.push("--resize", state.resize.trim());
|
|
35
|
+
return new Promise((done) => {
|
|
36
|
+
const child = spawn(process.execPath, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
37
|
+
let output = "";
|
|
38
|
+
child.stdout.on("data", (data) => output += data.toString());
|
|
39
|
+
child.stderr.on("data", (data) => output += data.toString());
|
|
40
|
+
child.once("error", (error) => done({ ok: false, message: error.message }));
|
|
41
|
+
child.once("exit", (code) => done({
|
|
42
|
+
ok: code === 0,
|
|
43
|
+
message: output.trim().split("\n").at(-1) || `Conversion exited with code ${code}`,
|
|
44
|
+
}));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
export async function runTui() {
|
|
48
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
49
|
+
process.stderr.write("cvt: interactive mode needs a terminal. Run cvt --help for command options.\n");
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const state = {
|
|
54
|
+
source: "clipboard",
|
|
55
|
+
files: "",
|
|
56
|
+
format: "gif",
|
|
57
|
+
output: "clipboard.gif",
|
|
58
|
+
delay: "10",
|
|
59
|
+
resize: "",
|
|
60
|
+
focus: 0,
|
|
61
|
+
status: "Ready",
|
|
62
|
+
error: "",
|
|
63
|
+
busy: false,
|
|
64
|
+
};
|
|
65
|
+
let previousDefault = defaultOutput(state);
|
|
66
|
+
let stopped = false;
|
|
67
|
+
const updateDefaultOutput = () => {
|
|
68
|
+
const next = defaultOutput(state);
|
|
69
|
+
if (!state.output || state.output === previousDefault)
|
|
70
|
+
state.output = next;
|
|
71
|
+
previousDefault = next;
|
|
72
|
+
};
|
|
73
|
+
const render = () => {
|
|
74
|
+
const width = Math.max(54, Math.min(process.stdout.columns || 80, 92));
|
|
75
|
+
const inner = width - 4;
|
|
76
|
+
const lines = [];
|
|
77
|
+
hitRows.clear();
|
|
78
|
+
const add = (content = "", field) => {
|
|
79
|
+
lines.push(`${ESC}2K ${content}`);
|
|
80
|
+
if (field)
|
|
81
|
+
hitRows.set(lines.length, field);
|
|
82
|
+
};
|
|
83
|
+
const selected = (field) => fields[state.focus] === field;
|
|
84
|
+
const marker = (field) => selected(field) ? `${ESC}38;5;81m›${ESC}0m` : " ";
|
|
85
|
+
const input = (field, value, placeholder) => {
|
|
86
|
+
const shown = value || `${ESC}2m${placeholder}${ESC}22m`;
|
|
87
|
+
return `${marker(field)} ${field === "files" ? "Files " : field === "output" ? "Output" : field === "delay" ? "Delay " : "Resize"} ${ESC}48;5;236m ${pad(shown, inner - 13)} ${ESC}0m`;
|
|
88
|
+
};
|
|
89
|
+
lines.push(`${ESC}2J${ESC}H`);
|
|
90
|
+
add(`${ESC}1;38;5;81mCVT${ESC}0m ${ESC}2mimage converter${ESC}0m`);
|
|
91
|
+
add("─".repeat(inner));
|
|
92
|
+
add(`${marker("source")} Source ${state.source === "clipboard" ? `${ESC}48;5;81;30m Clipboard ${ESC}0m Files` : `Clipboard ${ESC}48;5;81;30m Files ${ESC}0m`}`, "source");
|
|
93
|
+
add();
|
|
94
|
+
add(input("files", state.files, state.source === "clipboard" ? "not used while Clipboard is selected" : "type or drag a file here; use | between frames"), "files");
|
|
95
|
+
add();
|
|
96
|
+
add(`${marker("format")} Format ${formats.map((format) => format === state.format ? `${ESC}48;5;81;30m ${format.toUpperCase()} ${ESC}0m` : ` ${format.toUpperCase()} `).join(" ")}`, "format");
|
|
97
|
+
add();
|
|
98
|
+
add(input("output", state.output, defaultOutput(state)), "output");
|
|
99
|
+
add();
|
|
100
|
+
add(input("delay", state.delay, "10, hundredths of a second"), "delay");
|
|
101
|
+
add(input("resize", state.resize, "optional, for example 800x600 or 50%"), "resize");
|
|
102
|
+
add();
|
|
103
|
+
add(`${marker("convert")} ${selected("convert") ? `${ESC}48;5;81;30m Convert ${ESC}0m` : `${ESC}48;5;238m Convert ${ESC}0m`} ${state.busy ? `${ESC}38;5;220mConverting…${ESC}0m` : state.status}`, "convert");
|
|
104
|
+
if (state.error) {
|
|
105
|
+
add(`${ESC}1;38;5;203mConversion failed${ESC}0m`);
|
|
106
|
+
add(`${ESC}38;5;203m${pad(state.error, inner)}${ESC}0m`);
|
|
107
|
+
}
|
|
108
|
+
add();
|
|
109
|
+
add(`${ESC}2m↑↓/Tab move ←→ choose Enter select mouse works q quit${ESC}0m`);
|
|
110
|
+
add(`${ESC}2mThe converted file is copied automatically. Paste it into any app.${ESC}0m`);
|
|
111
|
+
process.stdout.write(lines.join("\n"));
|
|
112
|
+
};
|
|
113
|
+
const cleanup = () => {
|
|
114
|
+
if (stopped)
|
|
115
|
+
return;
|
|
116
|
+
stopped = true;
|
|
117
|
+
process.stdin.setRawMode(false);
|
|
118
|
+
process.stdin.pause();
|
|
119
|
+
process.stdout.write(`${ESC}?1000l${ESC}?1006l${ESC}?25h${ESC}?1049l`);
|
|
120
|
+
};
|
|
121
|
+
const convert = async () => {
|
|
122
|
+
if (state.busy)
|
|
123
|
+
return;
|
|
124
|
+
if (state.source === "files" && parseFiles(state.files).length === 0) {
|
|
125
|
+
state.status = "Add at least one file";
|
|
126
|
+
render();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
state.busy = true;
|
|
130
|
+
state.status = "Working";
|
|
131
|
+
state.error = "";
|
|
132
|
+
render();
|
|
133
|
+
const result = await runConversion(state);
|
|
134
|
+
state.busy = false;
|
|
135
|
+
state.status = result.ok ? `${ESC}38;5;82m${result.message}${ESC}0m` : "Error";
|
|
136
|
+
state.error = result.ok ? "" : result.message.replace(/^cvt:\s*/, "");
|
|
137
|
+
render();
|
|
138
|
+
};
|
|
139
|
+
const choose = (direction) => {
|
|
140
|
+
const field = fields[state.focus];
|
|
141
|
+
if (field === "source") {
|
|
142
|
+
state.source = state.source === "clipboard" ? "files" : "clipboard";
|
|
143
|
+
updateDefaultOutput();
|
|
144
|
+
}
|
|
145
|
+
else if (field === "format") {
|
|
146
|
+
const index = formats.indexOf(state.format);
|
|
147
|
+
state.format = formats[(index + direction + formats.length) % formats.length];
|
|
148
|
+
updateDefaultOutput();
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
const edit = (text) => {
|
|
152
|
+
const field = fields[state.focus];
|
|
153
|
+
if (field === "files" || field === "output" || field === "delay" || field === "resize") {
|
|
154
|
+
state[field] += text;
|
|
155
|
+
if (field === "files")
|
|
156
|
+
updateDefaultOutput();
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
emitKeypressEvents(process.stdin);
|
|
160
|
+
process.stdin.setRawMode(true);
|
|
161
|
+
process.stdin.resume();
|
|
162
|
+
process.stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?1000h${ESC}?1006h`);
|
|
163
|
+
render();
|
|
164
|
+
process.stdout.on("resize", render);
|
|
165
|
+
process.stdin.on("keypress", async (text, key) => {
|
|
166
|
+
if (stopped || state.busy)
|
|
167
|
+
return;
|
|
168
|
+
if (key.ctrl && key.name === "c" || key.name === "escape" || (text === "q" && !["files", "output", "delay", "resize"].includes(fields[state.focus]))) {
|
|
169
|
+
cleanup();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (key.name === "tab" || key.name === "down")
|
|
173
|
+
state.focus = (state.focus + 1) % fields.length;
|
|
174
|
+
else if (key.name === "up")
|
|
175
|
+
state.focus = (state.focus - 1 + fields.length) % fields.length;
|
|
176
|
+
else if (key.name === "left")
|
|
177
|
+
choose(-1);
|
|
178
|
+
else if (key.name === "right")
|
|
179
|
+
choose(1);
|
|
180
|
+
else if (key.name === "return") {
|
|
181
|
+
const field = fields[state.focus];
|
|
182
|
+
if (field === "convert")
|
|
183
|
+
await convert();
|
|
184
|
+
else if (field === "source" || field === "format")
|
|
185
|
+
choose(1);
|
|
186
|
+
else
|
|
187
|
+
state.focus = (state.focus + 1) % fields.length;
|
|
188
|
+
}
|
|
189
|
+
else if (key.name === "backspace") {
|
|
190
|
+
const field = fields[state.focus];
|
|
191
|
+
if (field === "files" || field === "output" || field === "delay" || field === "resize") {
|
|
192
|
+
state[field] = state[field].slice(0, -1);
|
|
193
|
+
if (field === "files")
|
|
194
|
+
updateDefaultOutput();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
else if (text && !key.ctrl && !key.meta && text >= " ")
|
|
198
|
+
edit(text);
|
|
199
|
+
render();
|
|
200
|
+
});
|
|
201
|
+
process.stdin.on("data", (data) => {
|
|
202
|
+
const match = data.toString().match(/\u001b\[<0;(\d+);(\d+)M/);
|
|
203
|
+
if (!match)
|
|
204
|
+
return;
|
|
205
|
+
const x = Number(match[1]);
|
|
206
|
+
const y = Number(match[2]);
|
|
207
|
+
const field = hitRows.get(y);
|
|
208
|
+
if (!field)
|
|
209
|
+
return;
|
|
210
|
+
state.focus = fields.indexOf(field);
|
|
211
|
+
if (field === "source")
|
|
212
|
+
choose(1);
|
|
213
|
+
if (field === "format") {
|
|
214
|
+
const start = 12;
|
|
215
|
+
const approximate = Math.max(0, Math.min(formats.length - 1, Math.floor((x - start) / 7)));
|
|
216
|
+
state.format = formats[approximate];
|
|
217
|
+
updateDefaultOutput();
|
|
218
|
+
}
|
|
219
|
+
if (field === "convert")
|
|
220
|
+
void convert();
|
|
221
|
+
render();
|
|
222
|
+
});
|
|
223
|
+
await new Promise((done) => {
|
|
224
|
+
const timer = setInterval(() => {
|
|
225
|
+
if (stopped) {
|
|
226
|
+
clearInterval(timer);
|
|
227
|
+
done();
|
|
228
|
+
}
|
|
229
|
+
}, 50);
|
|
230
|
+
});
|
|
231
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lagandevs/cvt",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Fast image conversion from files or the desktop clipboard",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cvt": "dist/cli.js",
|
|
8
|
+
"convert-file": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"scripts",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/LaganYT/convertCLI.git"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/LaganYT/convertCLI/issues"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/LaganYT/convertCLI#readme",
|
|
23
|
+
"keywords": [
|
|
24
|
+
"cli",
|
|
25
|
+
"image",
|
|
26
|
+
"converter",
|
|
27
|
+
"gif",
|
|
28
|
+
"imagemagick"
|
|
29
|
+
],
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@imagemagick/magick-wasm": "0.0.43",
|
|
38
|
+
"commander": "^14.0.1"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^24.13.3",
|
|
42
|
+
"typescript": "^5.9.2"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsc && node scripts/make-executable.mjs",
|
|
46
|
+
"dev": "pnpm build && node dist/cli.js",
|
|
47
|
+
"check": "tsc --noEmit",
|
|
48
|
+
"test": "pnpm build && node --test"
|
|
49
|
+
}
|
|
50
|
+
}
|