@pz4l/tinyimg-core 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,688 @@
1
+ [English](README.md) | [简体中文](README.zh-CN.md)
2
+
3
+ # tinyimg-core
4
+
5
+ Core library for TinyPNG image compression with intelligent caching and multi-API key management.
6
+
7
+ ## Features
8
+
9
+ - Multi-API key management with smart rotation strategies
10
+ - MD5-based permanent caching system with two-level hierarchy
11
+ - Concurrent compression with rate limiting
12
+ - Cache statistics for monitoring and CLI display
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install @pz4l/tinyimg-core
18
+ ```
19
+
20
+ ## Cache System
21
+
22
+ ### Overview
23
+
24
+ TinyImg uses an MD5-based permanent cache for compressed images with two cache levels:
25
+
26
+ 1. **Project Cache** (priority): `node_modules/.tinyimg_cache/` - Relative to project root
27
+ 2. **Global Cache** (fallback): `~/.tinyimg/cache/` - Shared across projects
28
+
29
+ The cache system provides:
30
+
31
+ - Automatic cache hit detection using MD5 content hashing
32
+ - Atomic writes for concurrent safety
33
+ - Graceful corruption handling (silent re-compression)
34
+ - Statistics reporting for CLI display
35
+
36
+ ### API Reference
37
+
38
+ #### calculateMD5
39
+
40
+ Calculates MD5 hash of a file's content. Used as cache key for storing/retrieving compressed images.
41
+
42
+ ```typescript
43
+ import { calculateMD5 } from '@pz4l/tinyimg-core'
44
+
45
+ const hash = await calculateMD5('/path/to/image.png')
46
+ console.log(hash) // 'a1b2c3d4e5f6...'
47
+ ```
48
+
49
+ **Parameters:**
50
+
51
+ - `imagePath: string` - Absolute path to the image file
52
+
53
+ **Returns:** `Promise<string>` - 32-character hexadecimal MD5 hash
54
+
55
+ **Note:** Same content produces same hash regardless of filename or location.
56
+
57
+ #### getProjectCachePath
58
+
59
+ Returns the path to the project-level cache directory.
60
+
61
+ ```typescript
62
+ import { getProjectCachePath } from '@pz4l/tinyimg-core'
63
+
64
+ const cachePath = getProjectCachePath('/Users/test/project')
65
+ // Returns: '/Users/test/project/node_modules/.tinyimg_cache'
66
+ ```
67
+
68
+ **Parameters:**
69
+
70
+ - `projectRoot: string` - Absolute path to the project root directory
71
+
72
+ **Returns:** `string` - Path to project cache directory
73
+
74
+ #### getGlobalCachePath
75
+
76
+ Returns the path to the global cache directory (shared across projects).
77
+
78
+ ```typescript
79
+ import { getGlobalCachePath } from '@pz4l/tinyimg-core'
80
+
81
+ const cachePath = getGlobalCachePath()
82
+ // Returns: '/Users/username/.tinyimg/cache'
83
+ ```
84
+
85
+ **Returns:** `string` - Path to global cache directory
86
+
87
+ #### readCache
88
+
89
+ Reads cached compressed image from cache directories in priority order.
90
+
91
+ ```typescript
92
+ import { readCache } from '@pz4l/tinyimg-core'
93
+
94
+ const cached = await readCache('image.png', [
95
+ getProjectCachePath('/project'),
96
+ getGlobalCachePath()
97
+ ])
98
+
99
+ if (cached) {
100
+ console.log('Cache hit!')
101
+ }
102
+ else {
103
+ console.log('Cache miss')
104
+ }
105
+ ```
106
+
107
+ **Parameters:**
108
+
109
+ - `imagePath: string` - Absolute path to the source image
110
+ - `cacheDirs: string[]` - Array of cache directories in priority order
111
+
112
+ **Returns:** `Promise<Buffer | null>` - Cached compressed data or null if miss
113
+
114
+ **Behavior:**
115
+
116
+ - Iterates through cache directories in order
117
+ - Returns first successful read
118
+ - Returns null if all caches miss or corrupted
119
+ - Silent failure - no errors thrown for missing/corrupted cache
120
+
121
+ #### writeCache
122
+
123
+ Writes compressed image data to cache using atomic write pattern.
124
+
125
+ ```typescript
126
+ import { writeCache } from '@pz4l/tinyimg-core'
127
+
128
+ const compressed = await compressImage(image)
129
+ await writeCache('image.png', compressed, getProjectCachePath('/project'))
130
+ ```
131
+
132
+ **Parameters:**
133
+
134
+ - `imagePath: string` - Absolute path to the source image
135
+ - `data: Buffer` - Compressed image data to cache
136
+ - `cacheDir: string` - Cache directory to write to
137
+
138
+ **Behavior:**
139
+
140
+ - Uses atomic write (temp file + rename) for concurrent safety
141
+ - Auto-creates cache directory if needed
142
+ - Safe for concurrent writes from multiple processes
143
+
144
+ #### CacheStorage Class
145
+
146
+ Object-oriented interface for cache operations.
147
+
148
+ ```typescript
149
+ import { CacheStorage } from '@pz4l/tinyimg-core'
150
+
151
+ const storage = new CacheStorage('/path/to/cache')
152
+
153
+ // Get cache file path for an image
154
+ const cachePath = await storage.getCachePath('/path/to/image.png')
155
+
156
+ // Read from cache
157
+ const data = await storage.read('/path/to/image.png')
158
+
159
+ // Write to cache
160
+ await storage.write('/path/to/image.png', compressedData)
161
+ ```
162
+
163
+ **Constructor:**
164
+
165
+ - `cacheDir: string` - Cache directory path
166
+
167
+ **Methods:**
168
+
169
+ - `async getCachePath(imagePath: string): Promise<string>` - Get cache file path
170
+ - `async read(imagePath: string): Promise<Buffer | null>` - Read cached data
171
+ - `async write(imagePath: string, data: Buffer): Promise<void>` - Write data to cache
172
+
173
+ #### getCacheStats
174
+
175
+ Returns cache statistics (file count and total size) for a directory.
176
+
177
+ ```typescript
178
+ import { getCacheStats } from '@pz4l/tinyimg-core'
179
+
180
+ const stats = await getCacheStats('/path/to/cache')
181
+ console.log(`Files: ${stats.count}, Size: ${formatBytes(stats.size)}`)
182
+ ```
183
+
184
+ **Parameters:**
185
+
186
+ - `cacheDir: string` - Cache directory path
187
+
188
+ **Returns:** `Promise<CacheStats>` - Object with `count` and `size` (in bytes)
189
+
190
+ **Behavior:**
191
+
192
+ - Returns `{ count: 0, size: 0 }` for non-existent directories
193
+ - Handles errors gracefully (no exceptions thrown)
194
+
195
+ #### getAllCacheStats
196
+
197
+ Returns statistics for both project and global cache.
198
+
199
+ ```typescript
200
+ import { getAllCacheStats } from '@pz4l/tinyimg-core'
201
+
202
+ // Get both project and global stats
203
+ const stats = await getAllCacheStats('/project/path')
204
+ console.log(`Project: ${stats.project?.count} files`)
205
+ console.log(`Global: ${stats.global.count} files`)
206
+
207
+ // Get only global stats
208
+ const globalOnly = await getAllCacheStats()
209
+ console.log(`Global: ${globalOnly.global.count} files`)
210
+ ```
211
+
212
+ **Parameters:**
213
+
214
+ - `projectRoot?: string` - Optional project root directory
215
+
216
+ **Returns:** `Promise<{ project: CacheStats | null, global: CacheStats }>` - Statistics object
217
+
218
+ **Behavior:**
219
+
220
+ - Project stats is `null` if no `projectRoot` provided
221
+ - Global stats always returned
222
+
223
+ #### formatBytes
224
+
225
+ Converts bytes to human-readable format for CLI display.
226
+
227
+ ```typescript
228
+ import { formatBytes } from '@pz4l/tinyimg-core'
229
+
230
+ formatBytes(0) // "0 B"
231
+ formatBytes(512) // "512 B"
232
+ formatBytes(1024) // "1.00 KB"
233
+ formatBytes(1536) // "1.50 KB"
234
+ formatBytes(1048576) // "1.00 MB"
235
+ formatBytes(1073741824) // "1.00 GB"
236
+ ```
237
+
238
+ **Parameters:**
239
+
240
+ - `bytes: number` - Number of bytes
241
+
242
+ **Returns:** `string` - Formatted string (e.g., "1.23 MB", "456 KB")
243
+
244
+ **Units:** B, KB, MB, GB (using 1024 as threshold)
245
+
246
+ ### Usage Example
247
+
248
+ Complete example showing cache read/write pattern:
249
+
250
+ ```typescript
251
+ import {
252
+ formatBytes,
253
+ getAllCacheStats,
254
+ getGlobalCachePath,
255
+ getProjectCachePath,
256
+ readCache,
257
+ writeCache
258
+ } from 'tinyimg-core'
259
+
260
+ async function compressWithCache(imagePath: string, projectRoot: string) {
261
+ // Try to read from cache (project first, then global)
262
+ const cached = await readCache(imagePath, [
263
+ getProjectCachePath(projectRoot),
264
+ getGlobalCachePath()
265
+ ])
266
+
267
+ if (cached) {
268
+ console.log('Cache hit! Using compressed image.')
269
+ return cached
270
+ }
271
+
272
+ // Cache miss - compress image
273
+ console.log('Cache miss. Compressing image...')
274
+ const compressed = await compressImage(imagePath)
275
+
276
+ // Write to project cache
277
+ await writeCache(imagePath, compressed, getProjectCachePath(projectRoot))
278
+
279
+ return compressed
280
+ }
281
+
282
+ async function showCacheStats(projectRoot: string) {
283
+ const stats = await getAllCacheStats(projectRoot)
284
+
285
+ console.log('Cache Statistics:')
286
+ console.log(`Project: ${stats.project?.count || 0} files, ${formatBytes(stats.project?.size || 0)}`)
287
+ console.log(`Global: ${stats.global.count} files, ${formatBytes(stats.global.size)}`)
288
+ }
289
+ ```
290
+
291
+ ### Cache Behavior
292
+
293
+ **Cache Key:**
294
+
295
+ - MD5 hash of original image content
296
+ - Same content = same hash, regardless of filename/location
297
+
298
+ **Cache File:**
299
+
300
+ - Filename: MD5 hash (no extension)
301
+ - Content: Compressed image data (Buffer)
302
+
303
+ **Cache Policy:**
304
+
305
+ - No TTL - cache is permanent until manually cleaned
306
+ - Corrupted cache files handled gracefully (silent re-compression)
307
+ - Atomic writes prevent concurrent write corruption
308
+
309
+ **Cache Priority:**
310
+
311
+ 1. Project cache checked first (fastest, project-specific)
312
+ 2. Global cache checked second (shared, fallback)
313
+
314
+ **Storage Locations:**
315
+
316
+ - Project: `<projectRoot>/node_modules/.tinyimg_cache/`
317
+ - Global: `~/.tinyimg/cache/`
318
+
319
+ ## API Key Management
320
+
321
+ Coming soon in Phase 2 documentation.
322
+
323
+ ## Compression API
324
+
325
+ ### Overview
326
+
327
+ The compression API provides programmatic access to TinyPNG image compression with intelligent caching, multi-key management, and fallback strategies.
328
+
329
+ ### compressImage
330
+
331
+ Compresses a single image with cache integration and automatic fallback.
332
+
333
+ ```typescript
334
+ import { compressImage } from '@pz4l/tinyimg-core'
335
+
336
+ const imageBuffer = Buffer.from(/* image data */)
337
+ const compressed = await compressImage(imageBuffer, {
338
+ mode: 'auto', // 'auto' | 'api' | 'web'
339
+ cache: true, // Enable caching (default: true)
340
+ maxRetries: 8, // Max retry attempts (default: 8)
341
+ })
342
+ ```
343
+
344
+ **Signature:**
345
+
346
+ ```typescript
347
+ async function compressImage(
348
+ buffer: Buffer,
349
+ options?: CompressServiceOptions
350
+ ): Promise<Buffer>
351
+ ```
352
+
353
+ **Parameters:**
354
+
355
+ - `buffer: Buffer` - Original image data as Node.js Buffer
356
+ - `options?: CompressServiceOptions` - Compression options (see below)
357
+
358
+ **Returns:** `Promise<Buffer>` - Compressed image data
359
+
360
+ **Behavior:**
361
+
362
+ - Checks cache first (project cache, then global cache)
363
+ - Compresses using API keys with automatic rotation
364
+ - Falls back to web compressor if all keys exhausted
365
+ - Writes result to project cache for future use
366
+ - Gracefully handles cache errors (continues with compression)
367
+
368
+ ### compressImages
369
+
370
+ Compresses multiple images with concurrency control.
371
+
372
+ ```typescript
373
+ import { compressImages } from '@pz4l/tinyimg-core'
374
+
375
+ const images = [buffer1, buffer2, buffer3]
376
+ const compressed = await compressImages(images, {
377
+ concurrency: 8, // Max parallel compressions (default: 8)
378
+ mode: 'auto',
379
+ cache: true,
380
+ })
381
+ ```
382
+
383
+ **Signature:**
384
+
385
+ ```typescript
386
+ async function compressImages(
387
+ buffers: Buffer[],
388
+ options?: CompressServiceOptions
389
+ ): Promise<Buffer[]>
390
+ ```
391
+
392
+ **Parameters:**
393
+
394
+ - `buffers: Buffer[]` - Array of image buffers to compress
395
+ - `options?: CompressServiceOptions` - Compression options
396
+
397
+ **Returns:** `Promise<Buffer[]>` - Array of compressed image buffers (same order as input)
398
+
399
+ **Behavior:**
400
+
401
+ - Processes images with configurable concurrency limit
402
+ - Each image goes through the same pipeline as `compressImage`
403
+ - Maintains order of results matching input order
404
+ - Failed compressions will throw (use try/catch for individual handling)
405
+
406
+ ### KeyPool
407
+
408
+ Manages multiple API keys with automatic rotation and quota tracking.
409
+
410
+ ```typescript
411
+ import { KeyPool } from '@pz4l/tinyimg-core'
412
+
413
+ // Create pool with random strategy (default)
414
+ const pool = new KeyPool('random')
415
+
416
+ // Create pool with round-robin strategy
417
+ const pool = new KeyPool('round-robin')
418
+
419
+ // Create pool with priority strategy
420
+ const pool = new KeyPool('priority')
421
+ ```
422
+
423
+ **Constructor:**
424
+
425
+ ```typescript
426
+ new KeyPool(strategy?: KeyStrategy)
427
+ ```
428
+
429
+ **Parameters:**
430
+
431
+ - `strategy: KeyStrategy` - Key selection strategy: `'random'` | `'round-robin'` | `'priority'`
432
+ - `random` (default): Randomly select available keys
433
+ - `round-robin`: Cycle through keys in order
434
+ - `priority`: Prefer API keys, fallback to web compressor
435
+
436
+ **Methods:**
437
+
438
+ - `async selectKey(): Promise<string>` - Select and return an available API key
439
+ - `decrementQuota(): void` - Mark current key's quota as used
440
+ - `getCurrentKey(): string | null` - Get the currently selected key
441
+
442
+ **Throws:**
443
+
444
+ - `NoValidKeysError` - When no API keys are configured
445
+ - `AllKeysExhaustedError` - When all keys have exhausted their quota
446
+
447
+ ### Type Definitions
448
+
449
+ #### CompressServiceOptions
450
+
451
+ Options for compression operations.
452
+
453
+ ```typescript
454
+ interface CompressServiceOptions {
455
+ /** Compression mode (default: 'auto') */
456
+ mode?: 'auto' | 'api' | 'web'
457
+
458
+ /** Enable cache (default: true) */
459
+ cache?: boolean
460
+
461
+ /** Use project cache only, ignore global cache (default: false) */
462
+ projectCacheOnly?: boolean
463
+
464
+ /** Concurrency limit for batch operations (default: 8) */
465
+ concurrency?: number
466
+
467
+ /** Maximum retry attempts (default: 8) */
468
+ maxRetries?: number
469
+
470
+ /** Custom KeyPool instance for advanced usage */
471
+ keyPool?: KeyPool
472
+ }
473
+ ```
474
+
475
+ #### CompressOptions
476
+
477
+ Base compression options (used internally).
478
+
479
+ ```typescript
480
+ interface CompressOptions {
481
+ /** Compression mode (default: 'auto') */
482
+ mode?: 'auto' | 'api' | 'web'
483
+
484
+ /** Custom compressor array for fallback chain */
485
+ compressors?: ICompressor[]
486
+
487
+ /** Maximum retry attempts (default: 3) */
488
+ maxRetries?: number
489
+ }
490
+ ```
491
+
492
+ #### CompressionMode
493
+
494
+ Type for compression mode selection.
495
+
496
+ ```typescript
497
+ type CompressionMode = 'auto' | 'api' | 'web'
498
+ ```
499
+
500
+ #### KeyStrategy
501
+
502
+ Type for key pool strategy selection.
503
+
504
+ ```typescript
505
+ type KeyStrategy = 'random' | 'round-robin' | 'priority'
506
+ ```
507
+
508
+ ### Error Types
509
+
510
+ #### AllKeysExhaustedError
511
+
512
+ Thrown when all API keys have exhausted their quota.
513
+
514
+ ```typescript
515
+ import { AllKeysExhaustedError } from '@pz4l/tinyimg-core'
516
+
517
+ try {
518
+ await compressImage(buffer)
519
+ }
520
+ catch (error) {
521
+ if (error instanceof AllKeysExhaustedError) {
522
+ console.log('All API keys exhausted, falling back to web compressor')
523
+ }
524
+ }
525
+ ```
526
+
527
+ #### NoValidKeysError
528
+
529
+ Thrown when no API keys are configured.
530
+
531
+ ```typescript
532
+ import { NoValidKeysError } from '@pz4l/tinyimg-core'
533
+
534
+ try {
535
+ const pool = new KeyPool('random')
536
+ }
537
+ catch (error) {
538
+ if (error instanceof NoValidKeysError) {
539
+ console.log('Please configure API keys via TINYPNG_KEYS env var')
540
+ }
541
+ }
542
+ ```
543
+
544
+ #### AllCompressionFailedError
545
+
546
+ Thrown when all compression methods (API and web) have failed.
547
+
548
+ ```typescript
549
+ import { AllCompressionFailedError } from '@pz4l/tinyimg-core'
550
+
551
+ try {
552
+ await compressImage(buffer)
553
+ }
554
+ catch (error) {
555
+ if (error instanceof AllCompressionFailedError) {
556
+ console.log('Compression failed - image may be corrupted or unsupported')
557
+ }
558
+ }
559
+ ```
560
+
561
+ ### Complete Usage Example
562
+
563
+ Full workflow example showing compression with caching and error handling:
564
+
565
+ ```typescript
566
+ import { readFile, writeFile } from 'node:fs/promises'
567
+ import {
568
+ AllCompressionFailedError,
569
+ AllKeysExhaustedError,
570
+ compressImage,
571
+ compressImages,
572
+ formatBytes,
573
+ getAllCacheStats,
574
+ getGlobalCachePath,
575
+ getProjectCachePath,
576
+ KeyPool,
577
+ NoValidKeysError,
578
+ } from 'tinyimg-core'
579
+
580
+ // Single image compression
581
+ async function compressSingleImage(inputPath: string, outputPath: string) {
582
+ try {
583
+ const imageBuffer = await readFile(inputPath)
584
+
585
+ const compressed = await compressImage(imageBuffer, {
586
+ mode: 'auto', // Try API first, fallback to web
587
+ cache: true, // Enable caching
588
+ maxRetries: 8, // Retry on transient failures
589
+ })
590
+
591
+ await writeFile(outputPath, compressed)
592
+
593
+ const savings = ((1 - compressed.length / imageBuffer.length) * 100).toFixed(1)
594
+ console.log(`Compressed: ${savings}% reduction`)
595
+
596
+ return compressed
597
+ }
598
+ catch (error) {
599
+ if (error instanceof AllCompressionFailedError) {
600
+ console.error('Compression failed: All methods exhausted')
601
+ }
602
+ else if (error instanceof NoValidKeysError) {
603
+ console.error('No API keys configured. Set TINYPNG_KEYS env var.')
604
+ }
605
+ else {
606
+ console.error('Unexpected error:', error)
607
+ }
608
+ throw error
609
+ }
610
+ }
611
+
612
+ // Batch compression with concurrency
613
+ async function compressBatch(inputPaths: string[], outputDir: string) {
614
+ const images = await Promise.all(
615
+ inputPaths.map(path => readFile(path))
616
+ )
617
+
618
+ const compressed = await compressImages(images, {
619
+ concurrency: 8, // Process 8 images in parallel
620
+ mode: 'auto',
621
+ cache: true,
622
+ })
623
+
624
+ // Save results
625
+ await Promise.all(
626
+ compressed.map((data, i) =>
627
+ writeFile(`${outputDir}/compressed-${i}.png`, data)
628
+ )
629
+ )
630
+
631
+ // Calculate total savings
632
+ const originalSize = images.reduce((sum, buf) => sum + buf.length, 0)
633
+ const compressedSize = compressed.reduce((sum, buf) => sum + buf.length, 0)
634
+ const savings = ((1 - compressedSize / originalSize) * 100).toFixed(1)
635
+
636
+ console.log(`Batch complete: ${savings}% total reduction`)
637
+ return compressed
638
+ }
639
+
640
+ // Show cache statistics
641
+ async function showStats(projectRoot: string) {
642
+ const stats = await getAllCacheStats(projectRoot)
643
+
644
+ console.log('Cache Statistics:')
645
+ console.log(` Project: ${stats.project?.count || 0} files (${formatBytes(stats.project?.size || 0)})`)
646
+ console.log(` Global: ${stats.global.count} files (${formatBytes(stats.global.size)})`)
647
+ }
648
+
649
+ // Manual KeyPool usage (advanced)
650
+ async function manualKeyManagement() {
651
+ try {
652
+ const pool = new KeyPool('round-robin')
653
+
654
+ // Get a key for manual API calls
655
+ const key = await pool.selectKey()
656
+ console.log(`Using key: ${key.substring(0, 4)}****${key.slice(-4)}`)
657
+
658
+ // Mark quota as used after compression
659
+ pool.decrementQuota()
660
+ }
661
+ catch (error) {
662
+ if (error instanceof AllKeysExhaustedError) {
663
+ console.log('All keys exhausted - using web fallback')
664
+ }
665
+ }
666
+ }
667
+
668
+ // Run examples
669
+ async function main() {
670
+ await compressSingleImage('input.png', 'output.png')
671
+ await showStats(process.cwd())
672
+ }
673
+
674
+ main().catch(console.error)
675
+ ```
676
+
677
+ ## Error Handling
678
+
679
+ The cache system is designed to fail gracefully:
680
+
681
+ - Missing cache directory → Returns null (cache miss)
682
+ - Corrupted cache file → Returns null (triggers re-compression)
683
+ - Concurrent writes → Atomic write pattern prevents corruption
684
+ - Permission errors → Silent failure (logs warning)
685
+
686
+ ## License
687
+
688
+ MIT