@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.
@@ -0,0 +1,688 @@
1
+ [English](README.md) | 简体中文
2
+
3
+ # tinyimg-core
4
+
5
+ 基于 TinyPNG 的图片压缩核心库,支持智能缓存和多 API 密钥管理。
6
+
7
+ ## 特性
8
+
9
+ - 多 API 密钥管理,支持智能轮换策略
10
+ - 基于 MD5 的两级永久缓存系统
11
+ - 带限流的并发压缩
12
+ - 缓存统计,便于监控和 CLI 展示
13
+
14
+ ## 安装
15
+
16
+ ```bash
17
+ npm install @pz4l/tinyimg-core
18
+ ```
19
+
20
+ ## 缓存系统
21
+
22
+ ### 概述
23
+
24
+ TinyImg 使用基于 MD5 的永久缓存来存储压缩后的图片,包含两级缓存:
25
+
26
+ 1. **项目缓存**(优先):`node_modules/.tinyimg_cache/` - 相对于项目根目录
27
+ 2. **全局缓存**(后备):`~/.tinyimg/cache/` - 跨项目共享
28
+
29
+ 缓存系统提供:
30
+
31
+ - 使用 MD5 内容哈希的自动缓存命中检测
32
+ - 原子写入,保证并发安全
33
+ - 优雅的损坏处理(静默重新压缩)
34
+ - 用于 CLI 展示的统计报告
35
+
36
+ ### API 参考
37
+
38
+ #### calculateMD5
39
+
40
+ 计算文件内容的 MD5 哈希值,用作缓存键来存储和检索压缩图片。
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
+ **参数**
50
+
51
+ - `imagePath: string` - 图片文件的绝对路径
52
+
53
+ **返回值** `Promise<string>` - 32 位十六进制 MD5 哈希
54
+
55
+ **说明** 相同内容无论文件名或位置如何都会产生相同的哈希。
56
+
57
+ #### getProjectCachePath
58
+
59
+ 获取项目级缓存目录的路径。
60
+
61
+ ```typescript
62
+ import { getProjectCachePath } from '@pz4l/tinyimg-core'
63
+
64
+ const cachePath = getProjectCachePath('/Users/test/project')
65
+ // 返回: '/Users/test/project/node_modules/.tinyimg_cache'
66
+ ```
67
+
68
+ **参数**
69
+
70
+ - `projectRoot: string` - 项目根目录的绝对路径
71
+
72
+ **返回值** `string` - 项目缓存目录路径
73
+
74
+ #### getGlobalCachePath
75
+
76
+ 获取全局缓存目录的路径(跨项目共享)。
77
+
78
+ ```typescript
79
+ import { getGlobalCachePath } from '@pz4l/tinyimg-core'
80
+
81
+ const cachePath = getGlobalCachePath()
82
+ // 返回: '/Users/username/.tinyimg/cache'
83
+ ```
84
+
85
+ **返回值** `string` - 全局缓存目录路径
86
+
87
+ #### readCache
88
+
89
+ 按优先级顺序从缓存目录读取已缓存的压缩图片。
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('缓存命中!')
101
+ }
102
+ else {
103
+ console.log('缓存未命中')
104
+ }
105
+ ```
106
+
107
+ **参数**
108
+
109
+ - `imagePath: string` - 源图片的绝对路径
110
+ - `cacheDirs: string[]` - 按优先级排列的缓存目录数组
111
+
112
+ **返回值** `Promise<Buffer | null>` - 缓存的压缩数据,未命中时返回 null
113
+
114
+ **行为**
115
+
116
+ - 按顺序遍历缓存目录
117
+ - 返回第一个成功的读取
118
+ - 所有缓存未命中或损坏时返回 null
119
+ - 静默失败 - 对缺失或损坏的缓存不抛出错误
120
+
121
+ #### writeCache
122
+
123
+ 使用原子写入模式将压缩图片数据写入缓存。
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
+ **参数**
133
+
134
+ - `imagePath: string` - 源图片的绝对路径
135
+ - `data: Buffer` - 要缓存的压缩图片数据
136
+ - `cacheDir: string` - 要写入的缓存目录
137
+
138
+ **行为**
139
+
140
+ - 使用原子写入(临时文件 + 重命名)保证并发安全
141
+ - 自动创建缓存目录(如需要)
142
+ - 对多个进程的并发写入安全
143
+
144
+ #### CacheStorage 类
145
+
146
+ 缓存操作的面向对象接口。
147
+
148
+ ```typescript
149
+ import { CacheStorage } from '@pz4l/tinyimg-core'
150
+
151
+ const storage = new CacheStorage('/path/to/cache')
152
+
153
+ // 获取图片的缓存文件路径
154
+ const cachePath = await storage.getCachePath('/path/to/image.png')
155
+
156
+ // 从缓存读取
157
+ const data = await storage.read('/path/to/image.png')
158
+
159
+ // 写入缓存
160
+ await storage.write('/path/to/image.png', compressedData)
161
+ ```
162
+
163
+ **构造函数**
164
+
165
+ - `cacheDir: string` - 缓存目录路径
166
+
167
+ **方法**
168
+
169
+ - `async getCachePath(imagePath: string): Promise<string>` - 获取缓存文件路径
170
+ - `async read(imagePath: string): Promise<Buffer | null>` - 读取缓存数据
171
+ - `async write(imagePath: string, data: Buffer): Promise<void>` - 写入数据到缓存
172
+
173
+ #### getCacheStats
174
+
175
+ 获取目录的缓存统计信息(文件数和总大小)。
176
+
177
+ ```typescript
178
+ import { getCacheStats } from '@pz4l/tinyimg-core'
179
+
180
+ const stats = await getCacheStats('/path/to/cache')
181
+ console.log(`文件: ${stats.count}, 大小: ${formatBytes(stats.size)}`)
182
+ ```
183
+
184
+ **参数**
185
+
186
+ - `cacheDir: string` - 缓存目录路径
187
+
188
+ **返回值** `Promise<CacheStats>` - 包含 `count` 和 `size`(字节)的对象
189
+
190
+ **行为**
191
+
192
+ - 不存在的目录返回 `{ count: 0, size: 0 }`
193
+ - 优雅处理错误(不抛出异常)
194
+
195
+ #### getAllCacheStats
196
+
197
+ 获取项目和全局缓存的统计信息。
198
+
199
+ ```typescript
200
+ import { getAllCacheStats } from '@pz4l/tinyimg-core'
201
+
202
+ // 获取项目和全局统计
203
+ const stats = await getAllCacheStats('/project/path')
204
+ console.log(`项目: ${stats.project?.count} 个文件`)
205
+ console.log(`全局: ${stats.global.count} 个文件`)
206
+
207
+ // 仅获取全局统计
208
+ const globalOnly = await getAllCacheStats()
209
+ console.log(`全局: ${globalOnly.global.count} 个文件`)
210
+ ```
211
+
212
+ **参数**
213
+
214
+ - `projectRoot?: string` - 可选的项目根目录
215
+
216
+ **返回值** `Promise<{ project: CacheStats | null, global: CacheStats }>` - 统计信息对象
217
+
218
+ **行为**
219
+
220
+ - 未提供 `projectRoot` 时项目统计为 `null`
221
+ - 全局统计始终返回
222
+
223
+ #### formatBytes
224
+
225
+ 将字节转换为人类可读格式,用于 CLI 展示。
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
+ **参数**
239
+
240
+ - `bytes: number` - 字节数
241
+
242
+ **返回值** `string` - 格式化字符串(如 "1.23 MB"、"456 KB")
243
+
244
+ **单位** B、KB、MB、GB(使用 1024 作为阈值)
245
+
246
+ ### 使用示例
247
+
248
+ 展示缓存读/写模式的完整示例:
249
+
250
+ ```typescript
251
+ import {
252
+ formatBytes,
253
+ getAllCacheStats,
254
+ getGlobalCachePath,
255
+ getProjectCachePath,
256
+ readCache,
257
+ writeCache
258
+ } from '@pz4l/tinyimg-core'
259
+
260
+ async function compressWithCache(imagePath: string, projectRoot: string) {
261
+ // 尝试从缓存读取(项目优先,然后全局)
262
+ const cached = await readCache(imagePath, [
263
+ getProjectCachePath(projectRoot),
264
+ getGlobalCachePath()
265
+ ])
266
+
267
+ if (cached) {
268
+ console.log('缓存命中!使用压缩后的图片。')
269
+ return cached
270
+ }
271
+
272
+ // 缓存未命中 - 压缩图片
273
+ console.log('缓存未命中。正在压缩图片...')
274
+ const compressed = await compressImage(imagePath)
275
+
276
+ // 写入项目缓存
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('缓存统计:')
286
+ console.log(`项目: ${stats.project?.count || 0} 个文件, ${formatBytes(stats.project?.size || 0)}`)
287
+ console.log(`全局: ${stats.global.count} 个文件, ${formatBytes(stats.global.size)}`)
288
+ }
289
+ ```
290
+
291
+ ### 缓存行为
292
+
293
+ **缓存键:**
294
+
295
+ - 原始图片内容的 MD5 哈希
296
+ - 相同内容 = 相同哈希,无论文件名/位置
297
+
298
+ **缓存文件:**
299
+
300
+ - 文件名:MD5 哈希(无扩展名)
301
+ - 内容:压缩图片数据(Buffer)
302
+
303
+ **缓存策略:**
304
+
305
+ - 无 TTL - 缓存永久保留,直到手动清理
306
+ - 损坏的缓存文件优雅处理(静默重新压缩)
307
+ - 原子写入防止并发写入损坏
308
+
309
+ **缓存优先级:**
310
+
311
+ 1. 项目缓存优先检查(最快,项目特定)
312
+ 2. 全局缓存次之(共享,后备)
313
+
314
+ **存储位置:**
315
+
316
+ - 项目:`<projectRoot>/node_modules/.tinyimg_cache/`
317
+ - 全局:`~/.tinyimg/cache/`
318
+
319
+ ## API 密钥管理
320
+
321
+ Phase 2 文档中即将推出。
322
+
323
+ ## 压缩 API
324
+
325
+ ### 概述
326
+
327
+ 压缩 API 提供对 TinyPNG 图片压缩的程序化访问,支持智能缓存和多密钥管理。
328
+
329
+ ### compressImage
330
+
331
+ 压缩单张图片,集成缓存和自动后备。
332
+
333
+ ```typescript
334
+ import { compressImage } from '@pz4l/tinyimg-core'
335
+
336
+ const imageBuffer = Buffer.from(/* 图片数据 */)
337
+ const compressed = await compressImage(imageBuffer, {
338
+ mode: 'auto', // 'auto' | 'api' | 'web'
339
+ cache: true, // 启用缓存(默认: true)
340
+ maxRetries: 8, // 最大重试次数(默认: 8)
341
+ })
342
+ ```
343
+
344
+ **签名**
345
+
346
+ ```typescript
347
+ async function compressImage(
348
+ buffer: Buffer,
349
+ options?: CompressServiceOptions
350
+ ): Promise<Buffer>
351
+ ```
352
+
353
+ **参数**
354
+
355
+ - `buffer: Buffer` - 原始图片数据,Node.js Buffer 格式
356
+ - `options?: CompressServiceOptions` - 压缩选项(见下文)
357
+
358
+ **返回值** `Promise<Buffer>` - 压缩后的图片数据
359
+
360
+ **行为**
361
+
362
+ - 首先检查缓存(项目缓存,然后全局缓存)
363
+ - 使用 API 密钥压缩,自动轮换
364
+ - 所有密钥耗尽后降级到 web 压缩器
365
+ - 将结果写入项目缓存以供将来使用
366
+ - 优雅处理缓存错误(继续压缩)
367
+
368
+ ### compressImages
369
+
370
+ 压缩多张图片,支持并发控制。
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, // 最大并行压缩数(默认: 8)
378
+ mode: 'auto',
379
+ cache: true,
380
+ })
381
+ ```
382
+
383
+ **签名**
384
+
385
+ ```typescript
386
+ async function compressImages(
387
+ buffers: Buffer[],
388
+ options?: CompressServiceOptions
389
+ ): Promise<Buffer[]>
390
+ ```
391
+
392
+ **参数**
393
+
394
+ - `buffers: Buffer[]` - 要压缩的图片缓冲区数组
395
+ - `options?: CompressServiceOptions` - 压缩选项
396
+
397
+ **返回值** `Promise<Buffer[]>` - 压缩后的图片缓冲区数组(顺序与输入相同)
398
+
399
+ **行为**
400
+
401
+ - 使用可配置的并发限制处理图片
402
+ - 每张图片经过与 `compressImage` 相同的流程
403
+ - 保持结果顺序与输入顺序匹配
404
+ - 压缩失败将抛出异常(使用 try/catch 进行单独处理)
405
+
406
+ ### KeyPool
407
+
408
+ 管理多个 API 密钥,支持自动轮换和额度跟踪。
409
+
410
+ ```typescript
411
+ import { KeyPool } from '@pz4l/tinyimg-core'
412
+
413
+ // 创建随机策略的池(默认)
414
+ const pool = new KeyPool('random')
415
+
416
+ // 创建轮询策略的池
417
+ const pool = new KeyPool('round-robin')
418
+
419
+ // 创建优先级策略的池
420
+ const pool = new KeyPool('priority')
421
+ ```
422
+
423
+ **构造函数**
424
+
425
+ ```typescript
426
+ new KeyPool(strategy?: KeyStrategy)
427
+ ```
428
+
429
+ **参数**
430
+
431
+ - `strategy: KeyStrategy` - 密钥选择策略:`'random'` | `'round-robin'` | `'priority'`
432
+ - `random`(默认):随机选择可用密钥
433
+ - `round-robin`:按顺序循环使用密钥
434
+ - `priority`:优先使用 API 密钥,后备到 web 压缩器
435
+
436
+ **方法**
437
+
438
+ - `async selectKey(): Promise<string>` - 选择并返回一个可用的 API 密钥
439
+ - `decrementQuota(): void` - 将当前密钥的额度标记为已使用
440
+ - `getCurrentKey(): string | null` - 获取当前选择的密钥
441
+
442
+ **抛出:**
443
+
444
+ - `NoValidKeysError` - 未配置 API 密钥时
445
+ - `AllKeysExhaustedError` - 所有密钥额度耗尽时
446
+
447
+ ### 类型定义
448
+
449
+ #### CompressServiceOptions
450
+
451
+ 压缩操作的选项。
452
+
453
+ ```typescript
454
+ interface CompressServiceOptions {
455
+ /** 压缩模式(默认: 'auto') */
456
+ mode?: 'auto' | 'api' | 'web'
457
+
458
+ /** 启用缓存(默认: true) */
459
+ cache?: boolean
460
+
461
+ /** 仅使用项目缓存,忽略全局缓存(默认: false) */
462
+ projectCacheOnly?: boolean
463
+
464
+ /** 批量操作的并发限制(默认: 8) */
465
+ concurrency?: number
466
+
467
+ /** 最大重试次数(默认: 8) */
468
+ maxRetries?: number
469
+
470
+ /** 自定义 KeyPool 实例,用于高级用法 */
471
+ keyPool?: KeyPool
472
+ }
473
+ ```
474
+
475
+ #### CompressOptions
476
+
477
+ 基础压缩选项(内部使用)。
478
+
479
+ ```typescript
480
+ interface CompressOptions {
481
+ /** 压缩模式(默认: 'auto') */
482
+ mode?: 'auto' | 'api' | 'web'
483
+
484
+ /** 自定义压缩器数组,用于后备链 */
485
+ compressors?: ICompressor[]
486
+
487
+ /** 最大重试次数(默认: 3) */
488
+ maxRetries?: number
489
+ }
490
+ ```
491
+
492
+ #### CompressionMode
493
+
494
+ 压缩模式选择的类型。
495
+
496
+ ```typescript
497
+ type CompressionMode = 'auto' | 'api' | 'web'
498
+ ```
499
+
500
+ #### KeyStrategy
501
+
502
+ 密钥池策略选择的类型。
503
+
504
+ ```typescript
505
+ type KeyStrategy = 'random' | 'round-robin' | 'priority'
506
+ ```
507
+
508
+ ### 错误类型
509
+
510
+ #### AllKeysExhaustedError
511
+
512
+ 所有 API 密钥额度耗尽时抛出。
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('所有 API 密钥已耗尽,降级到 web 压缩器')
523
+ }
524
+ }
525
+ ```
526
+
527
+ #### NoValidKeysError
528
+
529
+ 未配置 API 密钥时抛出。
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('请通过 TINYPNG_KEYS 环境变量配置 API 密钥')
540
+ }
541
+ }
542
+ ```
543
+
544
+ #### AllCompressionFailedError
545
+
546
+ 所有压缩方法(API 和 web)失败时抛出。
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('压缩失败 - 图片可能损坏或不支持')
557
+ }
558
+ }
559
+ ```
560
+
561
+ ### 完整使用示例
562
+
563
+ 展示压缩、缓存和错误处理的完整工作流程示例:
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 '@pz4l/tinyimg-core'
579
+
580
+ // 单张图片压缩
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', // 优先尝试 API,后备到 web
587
+ cache: true, // 启用缓存
588
+ maxRetries: 8, // 瞬态故障时重试
589
+ })
590
+
591
+ await writeFile(outputPath, compressed)
592
+
593
+ const savings = ((1 - compressed.length / imageBuffer.length) * 100).toFixed(1)
594
+ console.log(`压缩完成: ${savings}% 的压缩率`)
595
+
596
+ return compressed
597
+ }
598
+ catch (error) {
599
+ if (error instanceof AllCompressionFailedError) {
600
+ console.error('压缩失败: 所有方法都已耗尽')
601
+ }
602
+ else if (error instanceof NoValidKeysError) {
603
+ console.error('未配置 API 密钥。请设置 TINYPNG_KEYS 环境变量。')
604
+ }
605
+ else {
606
+ console.error('意外错误:', error)
607
+ }
608
+ throw error
609
+ }
610
+ }
611
+
612
+ // 批量压缩,支持并发
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, // 并行处理 8 张图片
620
+ mode: 'auto',
621
+ cache: true,
622
+ })
623
+
624
+ // 保存结果
625
+ await Promise.all(
626
+ compressed.map((data, i) =>
627
+ writeFile(`${outputDir}/compressed-${i}.png`, data)
628
+ )
629
+ )
630
+
631
+ // 计算总节省
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(`批量完成: ${savings}% 总压缩率`)
637
+ return compressed
638
+ }
639
+
640
+ // 显示缓存统计
641
+ async function showStats(projectRoot: string) {
642
+ const stats = await getAllCacheStats(projectRoot)
643
+
644
+ console.log('缓存统计:')
645
+ console.log(` 项目: ${stats.project?.count || 0} 个文件 (${formatBytes(stats.project?.size || 0)})`)
646
+ console.log(` 全局: ${stats.global.count} 个文件 (${formatBytes(stats.global.size)})`)
647
+ }
648
+
649
+ // 手动 KeyPool 用法(高级)
650
+ async function manualKeyManagement() {
651
+ try {
652
+ const pool = new KeyPool('round-robin')
653
+
654
+ // 获取密钥用于手动 API 调用
655
+ const key = await pool.selectKey()
656
+ console.log(`使用密钥: ${key.substring(0, 4)}****${key.slice(-4)}`)
657
+
658
+ // 压缩后标记额度已使用
659
+ pool.decrementQuota()
660
+ }
661
+ catch (error) {
662
+ if (error instanceof AllKeysExhaustedError) {
663
+ console.log('所有密钥已耗尽 - 使用 web 后备')
664
+ }
665
+ }
666
+ }
667
+
668
+ // 运行示例
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
+ ## 错误处理
678
+
679
+ 缓存系统设计为优雅失败:
680
+
681
+ - 缺少缓存目录 → 返回 null(缓存未命中)
682
+ - 损坏的缓存文件 → 返回 null(触发重新压缩)
683
+ - 并发写入 → 原子写入模式防止损坏
684
+ - 权限错误 → 静默失败(记录警告)
685
+
686
+ ## License
687
+
688
+ MIT