poops-images 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.
@@ -0,0 +1,463 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import sharp from 'sharp'
4
+ import chokidar from 'chokidar'
5
+ import { validateConfig, configHash } from './config.js'
6
+ import { toSharpOptions, filterByUpscale } from './sizes.js'
7
+ import { convertFormat, resolveTargetFormats } from './formats.js'
8
+ import { discoverAll } from './discover.js'
9
+ import { analyzeImage } from './analyze.js'
10
+ import Cache from './cache.js'
11
+ import { processSvg } from './svg.js'
12
+ import log from './utils/log.js'
13
+ import { formatBytes, formatTime } from './utils/format.js'
14
+
15
+ export default class ImageProcessor {
16
+ constructor(config) {
17
+ this.config = validateConfig(config)
18
+ this.cache = new Cache(this.config.out, this.config.cache)
19
+ this.stats = { processed: 0, variants: 0, skipped: 0, bytes: 0, startTime: 0 }
20
+ this.watcher = null
21
+ }
22
+
23
+ async processAll(options = {}) {
24
+ const { force = false, dryRun = false } = options
25
+ this.stats = { processed: 0, variants: 0, skipped: 0, bytes: 0, startTime: Date.now() }
26
+
27
+ this.cache.load()
28
+
29
+ const hash = configHash(this.config)
30
+ if (this.cache.getConfigHash() !== hash) {
31
+ if (this.cache.getConfigHash() !== null) {
32
+ log({ tag: 'image', text: 'Config changed, reprocessing all images' })
33
+ }
34
+ this.cache.invalidateAll()
35
+ this.cache.setConfigHash(hash)
36
+ }
37
+
38
+ // Discover all file types in parallel
39
+ const { raster: sourceFiles, svg: svgFiles, gif: gifFiles } = await discoverAll(this.config)
40
+
41
+ if (sourceFiles.length > 0) {
42
+ log({ tag: 'image', text: `Found ${sourceFiles.length} source image(s)` })
43
+ }
44
+
45
+ // Raster pipeline — resize and format conversion
46
+ if (dryRun) {
47
+ for (const file of sourceFiles) {
48
+ log({ tag: 'image', text: 'Would process:', link: file })
49
+ }
50
+ } else {
51
+ const queue = [...sourceFiles]
52
+ const workers = []
53
+ for (let i = 0; i < this.config.concurrency; i++) {
54
+ workers.push(this._worker(queue, force))
55
+ }
56
+ await Promise.all(workers)
57
+ }
58
+
59
+ // SVG + GIF pipelines — run concurrently
60
+ await Promise.all([
61
+ this._processSvgPipeline(svgFiles, { force, dryRun }),
62
+ this._processGifPipeline(gifFiles, { force, dryRun })
63
+ ])
64
+
65
+ if (!dryRun) this.cache.save()
66
+
67
+ const elapsed = formatTime(Date.now() - this.stats.startTime)
68
+ log({
69
+ tag: 'image',
70
+ text: `\u2713 ${this.stats.processed} image(s) \u2192 ${this.stats.variants} variant(s)`,
71
+ size: this.stats.skipped > 0 ? `(${this.stats.skipped} skipped)` : '',
72
+ time: elapsed
73
+ })
74
+
75
+ return this.getStats()
76
+ }
77
+
78
+ async processImage(inputPath, force = false) {
79
+ const relativePath = path.relative(this.config.in, inputPath)
80
+
81
+ // Stat + cache check
82
+ let stat
83
+ try {
84
+ stat = fs.statSync(inputPath)
85
+ } catch {
86
+ log({ tag: 'error', text: 'Cannot read file:', link: inputPath })
87
+ return
88
+ }
89
+
90
+ if (!force && this.cache.shouldSkip(relativePath, stat.mtimeMs, stat.size)) {
91
+ this.stats.skipped++
92
+ return
93
+ }
94
+
95
+ log({ tag: 'image', text: 'Processing:', link: relativePath })
96
+
97
+ // Analyze — metadata, transparency, format normalization, effective sizes
98
+ const job = await analyzeImage(inputPath, this.config, stat)
99
+ if (!job) return
100
+
101
+ const startTime = Date.now()
102
+ const outputs = []
103
+
104
+ // Get previous outputs before processing (for stale cleanup)
105
+ const previousOutputs = this.cache.getOutputs(relativePath)
106
+
107
+ // Filter sizes by upscale constraints
108
+ const eligibleSizes = filterByUpscale(
109
+ job.effectiveSizes, job.sourceWidth, job.sourceHeight
110
+ )
111
+
112
+ for (const sizeDef of eligibleSizes) {
113
+ const conversionOnly = sizeDef.width === 0 && sizeDef.height === 0
114
+
115
+ // Resize
116
+ const sharpOpts = toSharpOptions(sizeDef)
117
+ const pipeline = sharpOpts ? sharp(inputPath).rotate().resize(sharpOpts) : sharp(inputPath).rotate()
118
+ const resizedBuffer = await pipeline.toBuffer({ resolveWithObject: true })
119
+ const actualWidth = resizedBuffer.info.width
120
+ const actualHeight = resizedBuffer.info.height
121
+
122
+ // Build output path
123
+ const outDir = path.join(this.config.out, job.parsed.dir)
124
+ fs.mkdirSync(outDir, { recursive: true })
125
+
126
+ let baseName
127
+ if (conversionOnly) {
128
+ baseName = job.parsed.name
129
+ } else if (sizeDef.name) {
130
+ baseName = `${job.parsed.name}-${sizeDef.name}-${actualWidth}w`
131
+ } else {
132
+ baseName = `${job.parsed.name}-${actualWidth}w`
133
+ }
134
+
135
+ // Resolve which formats to generate
136
+ const { formats: targetFormats, preEncoded } = await resolveTargetFormats(
137
+ job.outputExt, job.transparent, resizedBuffer.data,
138
+ this.config.format, this.config.quality
139
+ )
140
+
141
+ // Encode + write each target format
142
+ for (const targetExt of targetFormats) {
143
+ let buffer
144
+ try {
145
+ // Reuse pre-encoded buffer from smart format comparison when available
146
+ buffer = preEncoded.get(targetExt) || await convertFormat(
147
+ sharp(resizedBuffer.data), targetExt, this.config.quality
148
+ )
149
+ } catch (err) {
150
+ log({ tag: 'error', text: `Format conversion failed (${targetExt}): ${err.message}`, link: relativePath })
151
+ continue
152
+ }
153
+
154
+ const filename = `${baseName}.${targetExt}`
155
+ const outputPath = path.join(outDir, filename)
156
+ const relPath = path.join(job.parsed.dir, filename)
157
+
158
+ fs.writeFileSync(outputPath, buffer)
159
+ outputs.push({ path: relPath, width: actualWidth, height: actualHeight })
160
+ this.stats.variants++
161
+ this.stats.bytes += buffer.length
162
+
163
+ log({
164
+ tag: 'image',
165
+ text: 'Compiled:',
166
+ link: relPath,
167
+ size: formatBytes(buffer.length),
168
+ time: formatTime(Date.now() - startTime)
169
+ })
170
+ }
171
+ }
172
+
173
+ // Clean up stale outputs from previous runs of this source file
174
+ const newPaths = new Set(outputs.map(o => o.path))
175
+ for (const prev of previousOutputs) {
176
+ const prevPath = typeof prev === 'string' ? prev : prev.path
177
+ if (!newPaths.has(prevPath)) {
178
+ const fullPath = path.join(this.config.out, prevPath)
179
+ if (fs.existsSync(fullPath)) {
180
+ fs.unlinkSync(fullPath)
181
+ }
182
+ }
183
+ }
184
+
185
+ this.cache.setEntry(relativePath, {
186
+ mtime: job.mtime,
187
+ size: job.fileSize,
188
+ width: job.sourceWidth,
189
+ height: job.sourceHeight,
190
+ exif: job.exif,
191
+ outputs
192
+ })
193
+
194
+ this.stats.processed++
195
+ }
196
+
197
+ watch() {
198
+ const watchPath = path.resolve(this.config.in)
199
+
200
+ log({ tag: 'image', text: 'Watching for changes in', link: this.config.in })
201
+
202
+ this._watchQueue = []
203
+ this._watchProcessing = false
204
+
205
+ this.watcher = chokidar.watch(watchPath, {
206
+ ignoreInitial: true,
207
+ ignored: this.config.exclude
208
+ })
209
+
210
+ this.watcher.on('add', (filePath) => {
211
+ this._enqueueWatch(filePath, false)
212
+ })
213
+
214
+ this.watcher.on('change', (filePath) => {
215
+ this._enqueueWatch(filePath, true)
216
+ })
217
+
218
+ this.watcher.on('unlink', (filePath) => {
219
+ const relativePath = path.relative(this.config.in, filePath)
220
+ const outputs = this.cache.getOutputs(relativePath)
221
+
222
+ for (const output of outputs) {
223
+ const outputPath = typeof output === 'string' ? output : output.path
224
+ const fullPath = path.join(this.config.out, outputPath)
225
+ if (fs.existsSync(fullPath)) {
226
+ fs.unlinkSync(fullPath)
227
+ log({ tag: 'image', text: 'Removed:', link: outputPath })
228
+ }
229
+ }
230
+
231
+ this.cache.removeEntry(relativePath)
232
+ this.cache.save()
233
+ })
234
+
235
+ return this.watcher
236
+ }
237
+
238
+ stopWatch() {
239
+ if (this.watcher) {
240
+ this.watcher.close()
241
+ this.watcher = null
242
+ }
243
+ }
244
+
245
+ getStats() {
246
+ return {
247
+ processed: this.stats.processed,
248
+ variants: this.stats.variants,
249
+ skipped: this.stats.skipped,
250
+ bytes: this.stats.bytes,
251
+ elapsed: Date.now() - this.stats.startTime
252
+ }
253
+ }
254
+
255
+ async _processSvgPipeline(svgFiles, { force, dryRun }) {
256
+ for (const svgFile of svgFiles) {
257
+ const relativePath = path.relative(this.config.in, svgFile)
258
+
259
+ if (dryRun) {
260
+ log({ tag: 'image', text: 'Would minify:', link: relativePath })
261
+ continue
262
+ }
263
+
264
+ let stat
265
+ try {
266
+ stat = fs.statSync(svgFile)
267
+ } catch {
268
+ continue
269
+ }
270
+
271
+ if (!force && this.cache.shouldSkip(relativePath, stat.mtimeMs, stat.size)) {
272
+ this.stats.skipped++
273
+ continue
274
+ }
275
+
276
+ const result = await processSvg(svgFile, this.config.out, this.config.in)
277
+ if (result) {
278
+ this.cache.setEntry(relativePath, {
279
+ mtime: stat.mtimeMs,
280
+ size: stat.size,
281
+ outputs: [{ path: result.relativePath, width: result.width, height: result.height }]
282
+ })
283
+ this.stats.processed++
284
+ this.stats.variants++
285
+ this.stats.bytes += result.outputSize
286
+ }
287
+ }
288
+ }
289
+
290
+ async _processGifPipeline(gifFiles, { force, dryRun }) {
291
+ for (const gifFile of gifFiles) {
292
+ const relativePath = path.relative(this.config.in, gifFile)
293
+
294
+ if (dryRun) {
295
+ log({ tag: 'image', text: 'Would process:', link: relativePath })
296
+ continue
297
+ }
298
+
299
+ let stat
300
+ try {
301
+ stat = fs.statSync(gifFile)
302
+ } catch {
303
+ continue
304
+ }
305
+
306
+ if (!force && this.cache.shouldSkip(relativePath, stat.mtimeMs, stat.size)) {
307
+ this.stats.skipped++
308
+ continue
309
+ }
310
+
311
+ // Detect animation: pages > 1 means animated GIF
312
+ let meta
313
+ try {
314
+ meta = await sharp(gifFile).metadata()
315
+ } catch (err) {
316
+ log({ tag: 'error', text: `Failed to read GIF metadata: ${err.message}`, link: relativePath })
317
+ continue
318
+ }
319
+
320
+ if ((meta.pages || 1) > 1) {
321
+ // Animated GIF — copy as-is
322
+ const outPath = path.join(this.config.out, relativePath)
323
+ fs.mkdirSync(path.dirname(outPath), { recursive: true })
324
+ fs.copyFileSync(gifFile, outPath)
325
+
326
+ this.cache.setEntry(relativePath, {
327
+ mtime: stat.mtimeMs,
328
+ size: stat.size,
329
+ outputs: [{ path: relativePath, width: meta.width, height: meta.height }]
330
+ })
331
+ this.stats.processed++
332
+ this.stats.variants++
333
+ this.stats.bytes += stat.size
334
+
335
+ log({ tag: 'image', text: 'Copied:', link: relativePath, size: formatBytes(stat.size) })
336
+ } else {
337
+ // Static GIF — route through raster pipeline
338
+ await this.processImage(gifFile, force)
339
+ }
340
+ }
341
+ }
342
+
343
+ async _worker(queue, force) {
344
+ while (queue.length > 0) {
345
+ const file = queue.shift()
346
+ await this.processImage(file, force)
347
+ }
348
+ }
349
+
350
+ _enqueueWatch(filePath, force) {
351
+ this._watchQueue.push({ filePath, force })
352
+ this._drainWatchQueue()
353
+ }
354
+
355
+ async _drainWatchQueue() {
356
+ if (this._watchProcessing) return
357
+ this._watchProcessing = true
358
+
359
+ while (this._watchQueue.length > 0) {
360
+ const { filePath, force } = this._watchQueue.shift()
361
+ try {
362
+ if (this._isSvg(filePath)) {
363
+ await this._watchProcessSvg(filePath)
364
+ } else if (this._isGif(filePath)) {
365
+ await this._watchProcessGif(filePath, force)
366
+ } else if (this._matchesInclude(filePath)) {
367
+ await this.processImage(filePath, force)
368
+ this.cache.save()
369
+ }
370
+ } catch (err) {
371
+ log({ tag: 'error', text: `Watch processing failed: ${err.message}`, link: filePath })
372
+ }
373
+ }
374
+
375
+ this._watchProcessing = false
376
+ }
377
+
378
+ _matchesInclude(filePath) {
379
+ const ext = path.extname(filePath).toLowerCase().replace('.', '')
380
+ // Input formats for raster pipeline — these get normalized to web formats during processing
381
+ const supportedExts = ['jpg', 'jpeg', 'png', 'tiff', 'tif', 'webp', 'heic', 'heif']
382
+ return supportedExts.includes(ext)
383
+ }
384
+
385
+ _isSvg(filePath) {
386
+ return path.extname(filePath).toLowerCase() === '.svg'
387
+ }
388
+
389
+ _isGif(filePath) {
390
+ return path.extname(filePath).toLowerCase() === '.gif'
391
+ }
392
+
393
+ async _watchProcessGif(filePath, force) {
394
+ const relativePath = path.relative(this.config.in, filePath)
395
+
396
+ let meta
397
+ try {
398
+ meta = await sharp(filePath).metadata()
399
+ } catch (err) {
400
+ log({ tag: 'error', text: `Failed to read GIF metadata: ${err.message}`, link: relativePath })
401
+ return
402
+ }
403
+
404
+ if ((meta.pages || 1) > 1) {
405
+ await this._watchCopyGif(filePath)
406
+ } else {
407
+ await this.processImage(filePath, force)
408
+ this.cache.save()
409
+ }
410
+ }
411
+
412
+ async _watchCopyGif(filePath) {
413
+ const relativePath = path.relative(this.config.in, filePath)
414
+ let stat
415
+ try {
416
+ stat = fs.statSync(filePath)
417
+ } catch {
418
+ return
419
+ }
420
+
421
+ const outPath = path.join(this.config.out, relativePath)
422
+ fs.mkdirSync(path.dirname(outPath), { recursive: true })
423
+ fs.copyFileSync(filePath, outPath)
424
+
425
+ let gifWidth = null
426
+ let gifHeight = null
427
+ try {
428
+ const gifMeta = await sharp(filePath).metadata()
429
+ gifWidth = gifMeta.width
430
+ gifHeight = gifMeta.height
431
+ } catch (err) {
432
+ log({ tag: 'error', text: `Failed to read GIF metadata: ${err.message}`, link: relativePath })
433
+ }
434
+
435
+ this.cache.setEntry(relativePath, {
436
+ mtime: stat.mtimeMs,
437
+ size: stat.size,
438
+ outputs: [{ path: relativePath, width: gifWidth, height: gifHeight }]
439
+ })
440
+ this.cache.save()
441
+ log({ tag: 'image', text: 'Copied:', link: relativePath, size: formatBytes(stat.size) })
442
+ }
443
+
444
+ async _watchProcessSvg(filePath) {
445
+ const relativePath = path.relative(this.config.in, filePath)
446
+ let stat
447
+ try {
448
+ stat = fs.statSync(filePath)
449
+ } catch {
450
+ return
451
+ }
452
+
453
+ const result = await processSvg(filePath, this.config.out, this.config.in)
454
+ if (result) {
455
+ this.cache.setEntry(relativePath, {
456
+ mtime: stat.mtimeMs,
457
+ size: stat.size,
458
+ outputs: [{ path: result.relativePath, width: result.width, height: result.height }]
459
+ })
460
+ this.cache.save()
461
+ }
462
+ }
463
+ }
package/lib/sizes.js ADDED
@@ -0,0 +1,97 @@
1
+ const VALID_X_POSITIONS = ['left', 'center', 'right']
2
+ const VALID_Y_POSITIONS = ['top', 'center', 'bottom']
3
+
4
+ export function validateSize(size) {
5
+ if (size.name !== undefined && typeof size.name !== 'string') {
6
+ throw new Error('Size "name" must be a string if provided')
7
+ }
8
+
9
+ const name = size.name || ''
10
+ const width = size.width || 0
11
+ const height = size.height || 0
12
+
13
+ const label = name || `${width}x${height}`
14
+
15
+ // width=0 && height=0 is valid: conversion-only mode (no resize)
16
+
17
+ if (typeof width !== 'number' || width < 0) {
18
+ throw new Error(`Size "${label}": width must be a non-negative number`)
19
+ }
20
+
21
+ if (typeof height !== 'number' || height < 0) {
22
+ throw new Error(`Size "${label}": height must be a non-negative number`)
23
+ }
24
+
25
+ if (size.crop !== undefined && size.crop !== false && size.crop !== true && !Array.isArray(size.crop)) {
26
+ throw new Error(`Size "${label}": crop must be boolean or [x, y] array`)
27
+ }
28
+
29
+ if (Array.isArray(size.crop)) {
30
+ if (size.crop.length !== 2) {
31
+ throw new Error(`Size "${label}": crop array must have exactly 2 elements [x, y]`)
32
+ }
33
+ if (!VALID_X_POSITIONS.includes(size.crop[0])) {
34
+ throw new Error(`Size "${label}": crop x must be one of: ${VALID_X_POSITIONS.join(', ')}`)
35
+ }
36
+ if (!VALID_Y_POSITIONS.includes(size.crop[1])) {
37
+ throw new Error(`Size "${label}": crop y must be one of: ${VALID_Y_POSITIONS.join(', ')}`)
38
+ }
39
+ }
40
+
41
+ return {
42
+ name,
43
+ width,
44
+ height,
45
+ crop: size.crop || false
46
+ }
47
+ }
48
+
49
+ // sharp accepts: 'top', 'right top', 'right', 'right bottom', 'bottom',
50
+ // 'left bottom', 'left', 'left top', 'centre'/'center'
51
+ // When center is one axis, drop it: ['center','top'] → 'top', ['left','center'] → 'left'
52
+ // When both are center: ['center','center'] → 'centre'
53
+ function cropArrayToPosition([x, y]) {
54
+ if (x === 'center' && y === 'center') return 'centre'
55
+ if (x === 'center') return y
56
+ if (y === 'center') return x
57
+ return `${x} ${y}`
58
+ }
59
+
60
+ export function filterByUpscale(sizes, sourceWidth, sourceHeight) {
61
+ return sizes.filter(sizeDef => {
62
+ if (sizeDef.width === 0 && sizeDef.height === 0) return true
63
+
64
+ const tooNarrow = sizeDef.width > 0 && sourceWidth < sizeDef.width
65
+ const tooShort = sizeDef.height > 0 && sourceHeight < sizeDef.height
66
+
67
+ if (sizeDef.crop) return !(tooNarrow || tooShort)
68
+
69
+ if (sizeDef.width > 0 && sizeDef.height === 0 && tooNarrow) return false
70
+ if (sizeDef.height > 0 && sizeDef.width === 0 && tooShort) return false
71
+ if (sizeDef.width > 0 && sizeDef.height > 0 && tooNarrow && tooShort) return false
72
+ return true
73
+ })
74
+ }
75
+
76
+ export function toSharpOptions(size) {
77
+ // Conversion-only: no resize needed
78
+ if (size.width === 0 && size.height === 0) return null
79
+
80
+ const opts = {}
81
+
82
+ if (size.width > 0) opts.width = size.width
83
+ if (size.height > 0) opts.height = size.height
84
+
85
+ if (size.crop === false) {
86
+ opts.fit = 'inside'
87
+ opts.withoutEnlargement = true
88
+ } else if (size.crop === true) {
89
+ opts.fit = 'cover'
90
+ opts.position = 'centre'
91
+ } else if (Array.isArray(size.crop)) {
92
+ opts.fit = 'cover'
93
+ opts.position = cropArrayToPosition(size.crop)
94
+ }
95
+
96
+ return opts
97
+ }
package/lib/svg.js ADDED
@@ -0,0 +1,81 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { optimize } from 'svgo'
4
+ import log from './utils/log.js'
5
+ import { formatBytes } from './utils/format.js'
6
+
7
+ export function parseSvgDimensions(svgContent) {
8
+ // Match the opening <svg> tag (may span multiple lines)
9
+ const svgTagMatch = svgContent.match(/<svg\s[^>]*>/is)
10
+ if (!svgTagMatch) return { width: null, height: null }
11
+
12
+ const tag = svgTagMatch[0]
13
+
14
+ // Try explicit width/height attributes (unitless or px only)
15
+ const wMatch = tag.match(/\bwidth=["'](\d+(?:\.\d+)?)(px)?["']/i)
16
+ const hMatch = tag.match(/\bheight=["'](\d+(?:\.\d+)?)(px)?["']/i)
17
+
18
+ if (wMatch && hMatch) {
19
+ return {
20
+ width: Math.round(parseFloat(wMatch[1])),
21
+ height: Math.round(parseFloat(hMatch[1]))
22
+ }
23
+ }
24
+
25
+ // Fall back to viewBox
26
+ const vbMatch = tag.match(/\bviewBox=["']\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*["']/i)
27
+ if (vbMatch) {
28
+ return {
29
+ width: Math.round(parseFloat(vbMatch[3])),
30
+ height: Math.round(parseFloat(vbMatch[4]))
31
+ }
32
+ }
33
+
34
+ return { width: null, height: null }
35
+ }
36
+
37
+ export async function processSvg(inputPath, outputDir, inputDir) {
38
+ const relativePath = path.relative(inputDir, inputPath)
39
+ const outPath = path.join(outputDir, relativePath)
40
+ const outDir = path.dirname(outPath)
41
+
42
+ let source
43
+ try {
44
+ source = fs.readFileSync(inputPath, 'utf-8')
45
+ } catch (err) {
46
+ log({ tag: 'error', text: `Cannot read SVG: ${err.message}`, link: relativePath })
47
+ return null
48
+ }
49
+
50
+ const originalSize = Buffer.byteLength(source, 'utf-8')
51
+
52
+ let result
53
+ try {
54
+ result = optimize(source, {
55
+ path: inputPath,
56
+ multipass: true
57
+ })
58
+ } catch (err) {
59
+ log({ tag: 'error', text: `SVGO failed: ${err.message}`, link: relativePath })
60
+ return null
61
+ }
62
+
63
+ const optimizedSize = Buffer.byteLength(result.data, 'utf-8')
64
+ const saved = originalSize - optimizedSize
65
+ const pct = originalSize > 0 ? Math.round((saved / originalSize) * 100) : 0
66
+
67
+ // Parse dimensions from the optimized SVG
68
+ const { width, height } = parseSvgDimensions(result.data)
69
+
70
+ fs.mkdirSync(outDir, { recursive: true })
71
+ fs.writeFileSync(outPath, result.data, 'utf-8')
72
+
73
+ log({
74
+ tag: 'image',
75
+ text: 'Minified:',
76
+ link: relativePath,
77
+ size: `${formatBytes(optimizedSize)} (${pct > 0 ? '-' + pct + '%' : 'same'})`
78
+ })
79
+
80
+ return { relativePath, outputSize: optimizedSize, saved, width, height }
81
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Convert degrees/minutes/seconds to decimal degrees.
3
+ * @param {[number, number, number]} dms - [degrees, minutes, seconds]
4
+ * @param {string} ref - Cardinal direction: "N", "S", "E", or "W"
5
+ * @returns {number} Decimal degrees (negative for S/W)
6
+ * @example dmsToDecimal([35, 42, 55.01], 'N') // => 35.715281
7
+ * @example dmsToDecimal([139, 46, 16.85], 'E') // => 139.771347
8
+ * @example dmsToDecimal([33, 51, 54.55], 'S') // => -33.865153
9
+ */
10
+ export function dmsToDecimal(dms, ref) {
11
+ const [degrees, minutes, seconds] = dms
12
+ let decimal = degrees + minutes / 60 + seconds / 3600
13
+ if (ref === 'S' || ref === 'W') decimal = -decimal
14
+ return Math.round(decimal * 1000000) / 1000000
15
+ }
16
+
17
+ /**
18
+ * Format degrees/minutes/seconds as a human-readable string.
19
+ * @param {[number, number, number]} dms - [degrees, minutes, seconds]
20
+ * @param {string} ref - Cardinal direction: "N", "S", "E", or "W"
21
+ * @returns {string} Formatted string like `35° 42' 55.01" N`
22
+ * @example formatDms([35, 42, 55.01], 'N') // => '35° 42\' 55.01" N'
23
+ * @example formatDms([139, 46, 16.85], 'E') // => '139° 46\' 16.85" E'
24
+ */
25
+ export function formatDms(dms, ref) {
26
+ return `${dms[0]}° ${dms[1]}' ${dms[2]}" ${ref}`
27
+ }
28
+
29
+ export function formatBytes(bytes) {
30
+ if (bytes < 1024) return `${bytes}B`
31
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
32
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
33
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`
34
+ }
35
+
36
+ export function formatTime(ms) {
37
+ if (ms < 1000) return `${Math.round(ms)}ms`
38
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
39
+ if (ms < 3600000) return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`
40
+ const h = Math.floor(ms / 3600000)
41
+ const m = Math.round((ms % 3600000) / 60000)
42
+ return m > 0 ? `${h}h ${m}m` : `${h}h`
43
+ }