jsmdcui 0.5.0 → 0.7.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.
Binary file
@@ -0,0 +1,361 @@
1
+ #!/usr/bin/env jsmdcui
2
+
3
+ # Bun.Image Processor
4
+
5
+ Paste the path to a local image below (for example, `/home/me/photo.jpg`). The output file will be written next to the source as `original.resized.jpg` or `original.resized.png`; the source image will not be overwritten.
6
+
7
+ ```text#image-path
8
+ demo.jpg
9
+ ```
10
+
11
+ - [Read image metadata](javascript:readMetadata())
12
+
13
+ ```text#image-metadata
14
+ Metadata has not been read
15
+ ```
16
+
17
+ ## Dimensions
18
+
19
+ Width (required, positive integer):
20
+
21
+ ```text#resize-width
22
+ 800
23
+ ```
24
+
25
+ Height (leave blank to preserve the source aspect ratio):
26
+
27
+ ```text#resize-height
28
+
29
+ ```
30
+
31
+ ## Select Fit
32
+
33
+ - [ ] fill (stretch to the exact width and height; may distort the image)
34
+ - [x] inside (preserve the aspect ratio and fit within the dimensions)
35
+
36
+ ## Select Filter
37
+
38
+ - [x] lanczos3 (general-purpose and sharp for photos; default)
39
+ - [ ] lanczos2 (slightly softer with fewer ringing artifacts)
40
+ - [ ] mitchell (smooth gradients)
41
+ - [ ] cubic (sharper, but may produce ringing)
42
+ - [ ] mks2013 (Magic Kernel Sharp)
43
+ - [ ] mks2021 (Magic Kernel Sharp)
44
+ - [ ] bilinear (fast and soft)
45
+ - [ ] linear (fast and soft)
46
+ - [ ] box (area-average; useful for large integer downscales)
47
+ - [ ] nearest (pixel art and hard edges)
48
+
49
+ ## Select Without Enlargement
50
+
51
+ - [x] yes (do not enlarge a smaller source image)
52
+ - [ ] no (allow enlargement)
53
+
54
+ ## Orientation and mirroring
55
+
56
+ ## Select Auto Orient
57
+
58
+ - [x] yes (apply the JPEG EXIF orientation automatically)
59
+ - [ ] no
60
+
61
+ ## Select Rotate
62
+
63
+ - [x] 0°
64
+ - [ ] 90°
65
+ - [ ] 180°
66
+ - [ ] 270°
67
+
68
+ ## Select Flip
69
+
70
+ - [ ] yes (mirror vertically)
71
+ - [x] no
72
+
73
+ ## Select Flop
74
+
75
+ - [ ] yes (mirror horizontally)
76
+ - [x] no
77
+
78
+ ## Color
79
+
80
+ Brightness multiplier (`1` leaves brightness unchanged):
81
+
82
+ ```text#brightness
83
+ 1
84
+ ```
85
+
86
+ Saturation multiplier (`0` produces greyscale, `1` is unchanged, and values above `1` increase saturation):
87
+
88
+ ```text#saturation
89
+ 1
90
+ ```
91
+
92
+ ## Select Output Format
93
+
94
+ - [x] JPEG (.jpg)
95
+ - [ ] PNG (.png)
96
+
97
+ ## JPEG Options
98
+
99
+ Quality (`1`–`100`; default `80`):
100
+
101
+ ```text#jpeg-quality
102
+ 80
103
+ ```
104
+
105
+ ## Select Progressive JPEG
106
+
107
+ - [ ] yes
108
+ - [x] no
109
+
110
+ ## PNG Options
111
+
112
+ Compression level (`0`–`9`; default `6`):
113
+
114
+ ```text#png-compression
115
+ 6
116
+ ```
117
+
118
+ ## Select PNG Palette
119
+
120
+ - [ ] yes (indexed-color PNG)
121
+ - [x] no (full-color PNG)
122
+
123
+ Palette color count (used when Palette is enabled; `2`–`256`):
124
+
125
+ ```text#png-colors
126
+ 256
127
+ ```
128
+
129
+ ## Select PNG Dither
130
+
131
+ - [x] yes
132
+ - [ ] no
133
+
134
+ - [Process and write image](javascript:resizeAndWrite())
135
+
136
+ ## Processing Result
137
+
138
+ ```text#write-status
139
+ Not started
140
+ ```
141
+
142
+ ```js front
143
+ function firstWord(value) {
144
+ return String(value ?? '').trim().split(/\s+/)[0].toLowerCase();
145
+ }
146
+
147
+ function includesChoice(id, choices, fallback) {
148
+ const value = String($('#' + id).val() ?? '').toLowerCase();
149
+ return choices.find(choice => value.includes(choice)) ?? fallback;
150
+ }
151
+
152
+ function yes(id) {
153
+ return String($('#' + id).val() ?? '').toLowerCase().includes('yes');
154
+ }
155
+
156
+ function numberText(id) {
157
+ return $('#' + id).val().trim();
158
+ }
159
+
160
+ function describeError(error) {
161
+ if (error == null) return 'Unknown error (no error details were returned)';
162
+ if (typeof error === 'string') return error;
163
+ const details = [];
164
+ if (error?.code) details.push(`Code: ${error.code}`);
165
+ if (error?.name && error.name !== 'Error') details.push(`Type: ${error.name}`);
166
+ if (error?.message) details.push(`Message: ${error.message}`);
167
+ if (error?.error && error.error !== error) details.push(`Error: ${describeError(error.error)}`);
168
+ if (error?.cause && error.cause !== error) details.push(`Cause: ${describeError(error.cause)}`);
169
+ if (error?.stack && String(error.stack) !== String(error.message ?? ''))
170
+ details.push(`Stack: ${error.stack}`);
171
+ if (details.length) return details.join('\n');
172
+ try {
173
+ const json = JSON.stringify(error, null, 2);
174
+ if (json && json !== '{}') return json;
175
+ } catch {}
176
+ return String(error);
177
+ }
178
+
179
+ export async function readMetadata() {
180
+ const output = $('#image-metadata');
181
+ output.val('Reading metadata…');
182
+
183
+ try {
184
+ const inputPath = $('#image-path').val().trim();
185
+ const result = await rpc.readImageMetadata(inputPath);
186
+ if (!result || typeof result !== 'object' || result.ok !== true) {
187
+ const error = result && typeof result === 'object' && 'error' in result
188
+ ? result.error
189
+ : result;
190
+ output.val(`Metadata read failed:\n${describeError(error)}`);
191
+ return;
192
+ }
193
+ output.val(`Metadata for: ${result.inputPath}\n${JSON.stringify(result.metadata, null, 2)}`);
194
+ } catch (error) {
195
+ output.val(`Metadata read failed:\n${describeError(error)}`);
196
+ }
197
+ }
198
+
199
+ export async function resizeAndWrite() {
200
+ const status = $('#write-status');
201
+ status.val('Processing…');
202
+ let options;
203
+
204
+ try {
205
+ options = {
206
+ inputPath: $('#image-path').val().trim(),
207
+ width: numberText('resize-width'),
208
+ height: numberText('resize-height'),
209
+ fit: includesChoice('select-fit', ['fill', 'inside'], 'inside'),
210
+ filter: includesChoice('select-filter', [
211
+ 'nearest', 'box', 'bilinear', 'cubic', 'mitchell',
212
+ 'lanczos2', 'lanczos3', 'mks2013', 'mks2021', 'linear',
213
+ ], 'lanczos3'),
214
+ withoutEnlargement: yes('select-without-enlargement'),
215
+ autoOrient: yes('select-auto-orient'),
216
+ rotate: Number.parseInt(firstWord($('#select-rotate').val()), 10),
217
+ flip: yes('select-flip'),
218
+ flop: yes('select-flop'),
219
+ brightness: numberText('brightness'),
220
+ saturation: numberText('saturation'),
221
+ format: includesChoice('select-output-format', ['png', 'jpeg'], 'jpeg'),
222
+ jpegQuality: numberText('jpeg-quality'),
223
+ progressive: yes('select-progressive-jpeg'),
224
+ pngCompression: numberText('png-compression'),
225
+ pngPalette: yes('select-png-palette'),
226
+ pngColors: numberText('png-colors'),
227
+ pngDither: yes('select-png-dither'),
228
+ };
229
+ const result = await rpc.resizeImage(options);
230
+
231
+ if (!result || typeof result !== 'object' || result.ok !== true) {
232
+ const error = result && typeof result === 'object' && 'error' in result
233
+ ? result.error
234
+ : result;
235
+ status.val(`Write failed:\n${describeError(error)}\nOptions read:\n${JSON.stringify(options, null, 2)}`);
236
+ return;
237
+ }
238
+
239
+ status.val(`Successfully wrote: ${result.outputPath}\n${result.width}×${result.height}, ${result.bytes} bytes\nOptions read:\n${JSON.stringify(options, null, 2)}`);
240
+ } catch (error) {
241
+ const optionText = options ? `\nOptions read:\n${JSON.stringify(options, null, 2)}` : '';
242
+ status.val(`Write failed:\n${describeError(error)}${optionText}`);
243
+ }
244
+ }
245
+ ```
246
+
247
+ ```js back
248
+ import { dirname, extname, join, basename, resolve } from 'node:path';
249
+
250
+ function integer(value, name, { min = 1, max = Number.MAX_SAFE_INTEGER, optional = false } = {}) {
251
+ if (optional && String(value ?? '').trim() === '') return undefined;
252
+ const result = Number(value);
253
+ if (!Number.isInteger(result) || result < min || result > max)
254
+ throw new Error(`${name} must be an integer from ${min} to ${max}`);
255
+ return result;
256
+ }
257
+
258
+ function finite(value, name, { min = 0 } = {}) {
259
+ const result = Number(value);
260
+ if (!Number.isFinite(result) || result < min)
261
+ throw new Error(`${name} must be a number greater than or equal to ${min}`);
262
+ return result;
263
+ }
264
+
265
+ function describeBackendError(error) {
266
+ if (error == null) return 'Unknown error (no error details were returned)';
267
+ if (typeof error === 'string') return error;
268
+ const details = [];
269
+ if (error?.code) details.push(`[${error.code}]`);
270
+ if (error?.name && error.name !== 'Error') details.push(error.name);
271
+ if (error?.message) details.push(error.message);
272
+ if (error?.cause && error.cause !== error)
273
+ details.push(`Cause: ${describeBackendError(error.cause)}`);
274
+ if (error?.stack) details.push(`Stack:\n${error.stack}`);
275
+ if (details.length) return details.join('\n');
276
+ try {
277
+ const json = JSON.stringify(error, null, 2);
278
+ if (json && json !== '{}') return json;
279
+ } catch {}
280
+ return String(error);
281
+ }
282
+
283
+ export async function readImageMetadata(inputText) {
284
+ try {
285
+ const pathText = String(inputText ?? '').trim();
286
+ if (!pathText) throw new Error('Paste an image path first');
287
+
288
+ const inputPath = resolve(pathText);
289
+ const inputFile = Bun.file(inputPath);
290
+ if (!await inputFile.exists()) throw new Error(`Input file not found: ${inputPath}`);
291
+
292
+ const metadata = await new Bun.Image(inputFile).metadata();
293
+ return { ok: true, inputPath, metadata };
294
+ } catch (error) {
295
+ return { ok: false, error: describeBackendError(error) };
296
+ }
297
+ }
298
+
299
+ export async function resizeImage(options = {}) {
300
+ try {
301
+ const inputText = String(options.inputPath ?? '').trim();
302
+ if (!inputText) throw new Error('Paste an image path first');
303
+
304
+ const inputPath = resolve(inputText);
305
+ const inputFile = Bun.file(inputPath);
306
+ if (!await inputFile.exists()) throw new Error(`Input file not found: ${inputPath}`);
307
+
308
+ const width = integer(options.width, 'Width');
309
+ const height = integer(options.height, 'Height', { optional: true });
310
+ const fit = options.fit === 'fill' ? 'fill' : 'inside';
311
+ const filters = new Set([
312
+ 'nearest', 'box', 'bilinear', 'linear', 'cubic', 'mitchell',
313
+ 'lanczos2', 'lanczos3', 'mks2013', 'mks2021',
314
+ ]);
315
+ const filter = filters.has(options.filter) ? options.filter : 'lanczos3';
316
+ const rotate = [0, 90, 180, 270].includes(options.rotate) ? options.rotate : 0;
317
+ const brightness = finite(options.brightness, 'Brightness');
318
+ const saturation = finite(options.saturation, 'Saturation');
319
+ const format = options.format === 'png' ? 'png' : 'jpeg';
320
+
321
+ let image = new Bun.Image(inputFile, { autoOrient: options.autoOrient !== false });
322
+ if (rotate) image = image.rotate(rotate);
323
+ if (options.flip) image = image.flip();
324
+ if (options.flop) image = image.flop();
325
+ image = image.resize(width, height, {
326
+ fit,
327
+ filter,
328
+ withoutEnlargement: Boolean(options.withoutEnlargement),
329
+ });
330
+ if (brightness !== 1 || saturation !== 1)
331
+ image = image.modulate({ brightness, saturation });
332
+
333
+ const originalName = basename(inputPath, extname(inputPath));
334
+ const outputPath = join(dirname(inputPath), `${originalName}.resized.${format === 'jpeg' ? 'jpg' : 'png'}`);
335
+
336
+ if (format === 'jpeg') {
337
+ image = image.jpeg({
338
+ quality: integer(options.jpegQuality, 'JPEG quality', { min: 1, max: 100 }),
339
+ progressive: Boolean(options.progressive),
340
+ });
341
+ } else {
342
+ const palette = Boolean(options.pngPalette);
343
+ const pngOptions = {
344
+ compressionLevel: integer(options.pngCompression, 'PNG compression level', { min: 0, max: 9 }),
345
+ palette,
346
+ };
347
+ if (palette) {
348
+ pngOptions.colors = integer(options.pngColors, 'PNG palette color count', { min: 2, max: 256 });
349
+ pngOptions.dither = Boolean(options.pngDither);
350
+ }
351
+ image = image.png(pngOptions);
352
+ }
353
+
354
+ const bytes = await image.write(outputPath);
355
+ const metadata = await new Bun.Image(outputPath).metadata();
356
+ return { ok: true, outputPath, bytes, width: metadata.width, height: metadata.height };
357
+ } catch (error) {
358
+ return { ok: false, error: describeBackendError(error) };
359
+ }
360
+ }
361
+ ```