@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 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
+ }
@@ -0,0 +1,5 @@
1
+ import { chmod } from "node:fs/promises";
2
+
3
+ if (process.platform !== "win32") {
4
+ await chmod(new URL("../dist/cli.js", import.meta.url), 0o755);
5
+ }