cantarella 1.2.0 → 1.3.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 CHANGED
@@ -24,6 +24,7 @@
24
24
  - **`--dry` 预览**:真实编码计算压缩后大小,但不写任何文件
25
25
  - **`--report` 报告**:在目标目录生成 markdown 压缩报告
26
26
  - **格式转换**:jpeg / png / webp / avif / tiff / gif 互转,支持 heic(iPhone 照片)与 svg 输入
27
+ - **PNG 档位压缩**:`-q` 映射到调色板档位(256 / 16 / 4 / 2 色台阶,档内用抖动强度微调);`-q 81+` 走真彩无损重编码(zlib 顶格 + 自适应行过滤)
27
28
  - **并发压缩**:基于 libuv 线程池的 4 路并发 + p-limit 控制
28
29
  - **发布纯 JS**:npm 包只含编译后的 JS,不携带 TS 源码;开发时 Node 原生跑 TS,零构建
29
30
 
@@ -75,7 +76,7 @@ cantarella watermark ./photos -t "x" --dry # 预览:真实合成但不写
75
76
 
76
77
  | 选项 | 说明 | 默认值 |
77
78
  | ------------------------ | ---------------------------------------------------------------------------------------------------------- | --------------------- |
78
- | `-q, --quality <number>` | 压缩质量 1-100(png 映射为压缩等级) | `80` |
79
+ | `-q, --quality <number>` | 压缩质量 1-100(png 映射为调色板颜色数) | `80` |
79
80
  | `-f, --format <format>` | 输出格式:`jpeg` `png` `webp` `avif` `tiff` `gif` | 保持原格式 |
80
81
  | `-w, --max-width <px>` | 等比缩放的最大宽度,只缩小不放大 | `0`(不缩放) |
81
82
  | `-r, --recursive` | 递归处理子目录 | 关闭 |
@@ -115,15 +116,26 @@ cantarella watermark ./photos -t "x" --dry # 预览:真实合成但不写
115
116
 
116
117
  ## 💡 PNG 压缩提示
117
118
 
118
- PNG 是无损格式,默认 `-q 80` 只做无损重编码——对已经优化过的 PNG 基本压不动(会被自动跳过)。想真正压小 PNG:
119
+ PNG 没有平滑的有损 quality,`-q` 被映射到调色板档位上——颜色数决定体积台阶(索引位宽 8/4/2/1bpp),档内用抖动强度微调画质与字节:
120
+
121
+ | quality | 档位 | 效果 |
122
+ | ------- | ---- | ---- |
123
+ | 81-100 | 真彩无损重编码 | zlib 顶格压缩 + 自适应行过滤,像素级保真(编码慢 4-6 倍) |
124
+ | 70-80 | 256 色量化 + 全抖动 | 近无损,默认 `-q 80` 即此档 |
125
+ | 40-69 | 16 色量化 | 体积第一次断崖;档内抖动随 quality 递减 |
126
+ | 10-39 | 4 色量化 | 再腰斩,观感受损明显 |
127
+ | 1-9 | 黑白二值 | 1bpp 极限 |
119
128
 
120
129
  ```bash
121
- cantarella compress ./images -q 60 # 开启 palette 量化(≤256 色),典型节省 80%+
130
+ cantarella compress ./images -q 80 # 默认档:256 色近无损量化
131
+ cantarella compress ./images -q 60 # 16 色:截图 / UI 图形的甜点档
132
+ cantarella compress ./images -q 85 # 无损真彩:像素级保真
133
+ cantarella compress ./images -s # smart 模式:无损 png 与无损 webp 同场比价
122
134
  cantarella compress ./images -f webp # 照片类收益最大
123
135
  ```
124
136
 
125
137
  - **截图 / UI 图形**(大面积纯色):`-q 60` 效果极好
126
- - **照片**存 PNG:palette 容易出色带,建议转 `webp` 或 `avif`
138
+ - **照片**存 PNG:默认 `-q 80` 的 256 色 + 全抖动已可放心用;色带敏感就用 `-q 85` 走无损
127
139
 
128
140
  ## 🛠️ 技术栈
129
141
 
@@ -146,6 +158,12 @@ pnpm build # 编译并压缩到 dist/(发布、npm link 前需要)
146
158
 
147
159
  ## 📜 版本记录
148
160
 
161
+ ### 🚀 1.3.0 · 2026-09-16
162
+
163
+ - ✨ **重构** PNG 的 `-q` 语义为档位表:旧公式"quality 越低压缩等级越高"存在语义倒置(`-q 100` 会算出 cl0 = 完全不压缩),且 `quality <= 60` 才开 palette 的门槛太高;现在 quality 直接踩在 PNG 的位深台阶上(256 / 16 / 4 / 2 色),档内用抖动强度微调画质与字节
164
+ - ✨ **新增** `-q 81+` 真彩无损重编码:`compressionLevel` 恒定 9(zlib 无损、压多狠都不损画质,没有理由不顶格)+ `adaptiveFiltering` 自适应行过滤,实测照片类比旧写法再小 20-40%(代价是编码慢 4-6 倍,与 avif 同量级的取舍)
165
+ - 💡 默认 `-q 80` 从"无损重编码"变为"256 色近无损量化 + 全抖动":已优化过的 PNG 默认档就能压动,照片类典型节省 80% 上下
166
+
149
167
  ### 🚀 1.2.0 · 2026-09-15
150
168
 
151
169
  - ✨ **新增** `watermark` 子命令:批量给图片加水印——图片 logo(`-m`)或文字(`-t`)二选一;九宫格定位(`-p`)、不透明度(`--opacity`)、大小按主图宽百分比(`-s`,图片默认 15 / 文字默认 5)
@@ -1 +1 @@
1
- import{InvalidArgumentError as r,Option as t}from"commander";import{compress as a}from"./compress.js";import{OUTPUT_FORMATS as p}from"./compressImage.js";import{watermark as s}from"./watermark.js";import{WATERMARK_POSITIONS as c}from"./watermarkImage.js";function m(n){const o=Number.parseInt(n,10);if(Number.isNaN(o)||o<1||o>100)throw new r("\u5FC5\u987B\u662F 1 - 100 \u7684\u6574\u6570");return o}function u(n){const o=Number.parseInt(n,10);if(Number.isNaN(o)||o<=0)throw new r("\u5FC5\u987B\u662F\u6B63\u6574\u6570");return o}function e(n){const o=n.trim();if(o===""||o==="."||o===".."||o.includes("/")||o.includes("\\"))throw new r("\u5FC5\u987B\u662F\u5355\u7EAF\u7684\u76EE\u5F55\u540D\uFF08\u4E0D\u80FD\u4E3A\u7A7A\uFF0C\u4E0D\u80FD\u5305\u542B / \\ . ..\uFF09");return o}function i(n){const o=Number.parseInt(n,10);if(Number.isNaN(o)||o<1||o>1024)throw new r("\u5FC5\u987B\u662F 1 - 1024 \u7684\u6574\u6570");return o}function f(n){const o=n.trim();if(o==="")throw new r("\u8F93\u51FA\u8DEF\u5F84\u4E0D\u80FD\u4E3A\u7A7A");return o}function N(n){const o=Number(n);if(Number.isNaN(o)||o<.1||o>1)throw new r("\u5FC5\u987B\u662F 0.1 - 1 \u4E4B\u95F4\u7684\u5C0F\u6570");return o}function w(n){const o=Number.parseInt(n,10);if(Number.isNaN(o)||o<1||o>100)throw new r("\u5FC5\u987B\u662F 1 - 100 \u7684\u6574\u6570");return o}function d(n){const o=Number.parseInt(n,10);if(Number.isNaN(o)||o<-89||o>89)throw new r("\u5FC5\u987B\u662F -89 - 89 \u7684\u6574\u6570\uFF080 = \u4E0D\u503E\u659C\uFF09");return o}const l=new t("-f, --format <format>","\u8F6C\u6362\u8F93\u51FA\u683C\u5F0F\uFF08\u9ED8\u8BA4\u4FDD\u6301\u539F\u683C\u5F0F\uFF09").choices([...p]);export function myCompress(n){n.command("compress [target]").description("\u6279\u91CF\u538B\u7F29\u56FE\u7247\uFF08\u8986\u76D6\u539F\u6587\u4EF6\u524D\u81EA\u52A8\u5907\u4EFD\u5230\u5907\u4EFD\u76EE\u5F55\uFF0C\u9ED8\u8BA4 .backup/\uFF09").option("-q, --quality <number>","\u538B\u7F29\u8D28\u91CF 1-100\uFF0C\u9ED8\u8BA4 80\uFF08png \u6620\u5C04\u4E3A\u538B\u7F29\u7B49\u7EA7\uFF09",m,80).addOption(l).option("-w, --max-width <px>","\u7B49\u6BD4\u7F29\u653E\u7684\u6700\u5927\u5BBD\u5EA6\uFF0C\u53EA\u7F29\u5C0F\u4E0D\u653E\u5927",u).option("-r, --recursive","\u9012\u5F52\u5904\u7406\u5B50\u76EE\u5F55",!1).option("--dry","\u9884\u89C8\u6A21\u5F0F\uFF1A\u771F\u5B9E\u8BA1\u7B97\u538B\u7F29\u540E\u5927\u5C0F\uFF0C\u4F46\u4E0D\u5199\u4EFB\u4F55\u6587\u4EF6",!1).option("--report","\u5728\u76EE\u6807\u76EE\u5F55\u751F\u6210 markdown \u538B\u7F29\u62A5\u544A",!1).option("-y, --yes","\u8DF3\u8FC7\u5168\u90E8\u95EE\u7B54\u3001\u76F4\u63A5\u7528\u9ED8\u8BA4\u503C\u5F00\u538B",!1).option("--backup-dir <name>","\u5907\u4EFD\u76EE\u5F55\u540D\uFF08\u9ED8\u8BA4 .backup\uFF09",e).option("-c, --concurrency <n>","\u540C\u65F6\u5904\u7406\u591A\u5C11\u5F20\u56FE\u7247",i,4).option("-o, --out <dir>","\u8F93\u51FA\u5230\u72EC\u7ACB\u76EE\u5F55\uFF08\u955C\u50CF\u6E90\u76EE\u5F55\u7ED3\u6784\uFF0C\u4E0D\u8986\u76D6\u539F\u56FE\uFF09",f).option("-s, --smart","\u6BCF\u5F20\u56FE\u5206\u522B\u6309\u591A\u4E2A\u5019\u9009\u683C\u5F0F\u7F16\u7801",!1).action(a)}const b=new t("-p, --position <pos>","\u6C34\u5370\u4F4D\u7F6E\uFF08\u4E5D\u5BAB\u683C\u7F29\u5199\uFF09").choices([...c]).default("br");export function myWatermark(n){n.command("watermark [target]").description("\u6279\u91CF\u7ED9\u56FE\u7247\u52A0\u6C34\u5370\uFF1A\u56FE\u7247 logo \u6216\u6587\u5B57\uFF0C\u652F\u6301\u4E5D\u5BAB\u683C\u5B9A\u4F4D\u4E0E\u5E73\u94FA\u9632\u76D7\u56FE").option("-m, --mark <file>","\u6C34\u5370\u56FE\u7247\u8DEF\u5F84\uFF08\u4E0E -t \u4E92\u65A5\uFF09").option("-t, --text <string>","\u6C34\u5370\u6587\u5B57\uFF08\u4E0E -m \u4E92\u65A5\uFF09").addOption(b).option("--opacity <0.1-1>","\u4E0D\u900F\u660E\u5EA6\uFF0C\u9ED8\u8BA4 0.5",N,.5).option("-s, --scale <1-100>","\u6C34\u5370\u5927\u5C0F = \u4E3B\u56FE\u5BBD\u7684\u767E\u5206\u6BD4\uFF08\u9ED8\u8BA4\uFF1A\u56FE\u7247 15\uFF0C\u6587\u5B57 5\uFF09",w).option("--tile","\u5E73\u94FA\u6574\u5F20\u56FE\uFF08\u9632\u76D7\u56FE\uFF09",!1).option("-r, --recursive","\u9012\u5F52\u5904\u7406\u5B50\u76EE\u5F55",!1).option("--dry","\u9884\u89C8\u6A21\u5F0F\uFF1A\u771F\u5B9E\u5408\u6210\uFF0C\u4F46\u4E0D\u5199\u4EFB\u4F55\u6587\u4EF6",!1).option("-y, --yes","\u8DF3\u8FC7\u5168\u90E8\u95EE\u7B54\u3001\u76F4\u63A5\u7528\u9ED8\u8BA4\u503C",!1).option("--backup-dir <name>","\u5907\u4EFD\u76EE\u5F55\u540D\uFF08\u9ED8\u8BA4 .backup\uFF09",e).option("-c, --concurrency <n>","\u540C\u65F6\u5904\u7406\u591A\u5C11\u5F20\u56FE\u7247",i,4).option("-a, --angle <deg>","\u5E73\u94FA\u6C34\u5370\u7684\u503E\u659C\u89D2\u5EA6\uFF08\u5EA6\uFF0C0 = \u4E0D\u503E\u659C\uFF09",d).action(s)}
1
+ import{InvalidArgumentError as r,Option as t}from"commander";import{compress as a}from"./compress.js";import{OUTPUT_FORMATS as p}from"./compressImage.js";import{watermark as s}from"./watermark.js";import{WATERMARK_POSITIONS as c}from"./watermarkImage.js";function m(n){const o=Number.parseInt(n,10);if(Number.isNaN(o)||o<1||o>100)throw new r("\u5FC5\u987B\u662F 1 - 100 \u7684\u6574\u6570");return o}function u(n){const o=Number.parseInt(n,10);if(Number.isNaN(o)||o<=0)throw new r("\u5FC5\u987B\u662F\u6B63\u6574\u6570");return o}function e(n){const o=n.trim();if(o===""||o==="."||o===".."||o.includes("/")||o.includes("\\"))throw new r("\u5FC5\u987B\u662F\u5355\u7EAF\u7684\u76EE\u5F55\u540D\uFF08\u4E0D\u80FD\u4E3A\u7A7A\uFF0C\u4E0D\u80FD\u5305\u542B / \\ . ..\uFF09");return o}function i(n){const o=Number.parseInt(n,10);if(Number.isNaN(o)||o<1||o>1024)throw new r("\u5FC5\u987B\u662F 1 - 1024 \u7684\u6574\u6570");return o}function f(n){const o=n.trim();if(o==="")throw new r("\u8F93\u51FA\u8DEF\u5F84\u4E0D\u80FD\u4E3A\u7A7A");return o}function N(n){const o=Number(n);if(Number.isNaN(o)||o<.1||o>1)throw new r("\u5FC5\u987B\u662F 0.1 - 1 \u4E4B\u95F4\u7684\u5C0F\u6570");return o}function w(n){const o=Number.parseInt(n,10);if(Number.isNaN(o)||o<1||o>100)throw new r("\u5FC5\u987B\u662F 1 - 100 \u7684\u6574\u6570");return o}function d(n){const o=Number.parseInt(n,10);if(Number.isNaN(o)||o<-89||o>89)throw new r("\u5FC5\u987B\u662F -89 - 89 \u7684\u6574\u6570\uFF080 = \u4E0D\u503E\u659C\uFF09");return o}const l=new t("-f, --format <format>","\u8F6C\u6362\u8F93\u51FA\u683C\u5F0F\uFF08\u9ED8\u8BA4\u4FDD\u6301\u539F\u683C\u5F0F\uFF09").choices([...p]);export function myCompress(n){n.command("compress [target]").description("\u6279\u91CF\u538B\u7F29\u56FE\u7247\uFF08\u8986\u76D6\u539F\u6587\u4EF6\u524D\u81EA\u52A8\u5907\u4EFD\u5230\u5907\u4EFD\u76EE\u5F55\uFF0C\u9ED8\u8BA4 .backup/\uFF09").option("-q, --quality <number>","\u538B\u7F29\u8D28\u91CF 1-100\uFF0C\u9ED8\u8BA4 80\uFF08png \u6620\u5C04\u4E3A\u8C03\u8272\u677F\u989C\u8272\u6570\uFF09",m,80).addOption(l).option("-w, --max-width <px>","\u7B49\u6BD4\u7F29\u653E\u7684\u6700\u5927\u5BBD\u5EA6\uFF0C\u53EA\u7F29\u5C0F\u4E0D\u653E\u5927",u).option("-r, --recursive","\u9012\u5F52\u5904\u7406\u5B50\u76EE\u5F55",!1).option("--dry","\u9884\u89C8\u6A21\u5F0F\uFF1A\u771F\u5B9E\u8BA1\u7B97\u538B\u7F29\u540E\u5927\u5C0F\uFF0C\u4F46\u4E0D\u5199\u4EFB\u4F55\u6587\u4EF6",!1).option("--report","\u5728\u76EE\u6807\u76EE\u5F55\u751F\u6210 markdown \u538B\u7F29\u62A5\u544A",!1).option("-y, --yes","\u8DF3\u8FC7\u5168\u90E8\u95EE\u7B54\u3001\u76F4\u63A5\u7528\u9ED8\u8BA4\u503C\u5F00\u538B",!1).option("--backup-dir <name>","\u5907\u4EFD\u76EE\u5F55\u540D\uFF08\u9ED8\u8BA4 .backup\uFF09",e).option("-c, --concurrency <n>","\u540C\u65F6\u5904\u7406\u591A\u5C11\u5F20\u56FE\u7247",i,4).option("-o, --out <dir>","\u8F93\u51FA\u5230\u72EC\u7ACB\u76EE\u5F55\uFF08\u955C\u50CF\u6E90\u76EE\u5F55\u7ED3\u6784\uFF0C\u4E0D\u8986\u76D6\u539F\u56FE\uFF09",f).option("-s, --smart","\u6BCF\u5F20\u56FE\u5206\u522B\u6309\u591A\u4E2A\u5019\u9009\u683C\u5F0F\u7F16\u7801",!1).action(a)}const b=new t("-p, --position <pos>","\u6C34\u5370\u4F4D\u7F6E\uFF08\u4E5D\u5BAB\u683C\u7F29\u5199\uFF09").choices([...c]).default("br");export function myWatermark(n){n.command("watermark [target]").description("\u6279\u91CF\u7ED9\u56FE\u7247\u52A0\u6C34\u5370\uFF1A\u56FE\u7247 logo \u6216\u6587\u5B57\uFF0C\u652F\u6301\u4E5D\u5BAB\u683C\u5B9A\u4F4D\u4E0E\u5E73\u94FA\u9632\u76D7\u56FE").option("-m, --mark <file>","\u6C34\u5370\u56FE\u7247\u8DEF\u5F84\uFF08\u4E0E -t \u4E92\u65A5\uFF09").option("-t, --text <string>","\u6C34\u5370\u6587\u5B57\uFF08\u4E0E -m \u4E92\u65A5\uFF09").addOption(b).option("--opacity <0.1-1>","\u4E0D\u900F\u660E\u5EA6\uFF0C\u9ED8\u8BA4 0.5",N,.5).option("-s, --scale <1-100>","\u6C34\u5370\u5927\u5C0F = \u4E3B\u56FE\u5BBD\u7684\u767E\u5206\u6BD4\uFF08\u9ED8\u8BA4\uFF1A\u56FE\u7247 15\uFF0C\u6587\u5B57 5\uFF09",w).option("--tile","\u5E73\u94FA\u6574\u5F20\u56FE\uFF08\u9632\u76D7\u56FE\uFF09",!1).option("-r, --recursive","\u9012\u5F52\u5904\u7406\u5B50\u76EE\u5F55",!1).option("--dry","\u9884\u89C8\u6A21\u5F0F\uFF1A\u771F\u5B9E\u5408\u6210\uFF0C\u4F46\u4E0D\u5199\u4EFB\u4F55\u6587\u4EF6",!1).option("-y, --yes","\u8DF3\u8FC7\u5168\u90E8\u95EE\u7B54\u3001\u76F4\u63A5\u7528\u9ED8\u8BA4\u503C",!1).option("--backup-dir <name>","\u5907\u4EFD\u76EE\u5F55\u540D\uFF08\u9ED8\u8BA4 .backup\uFF09",e).option("-c, --concurrency <n>","\u540C\u65F6\u5904\u7406\u591A\u5C11\u5F20\u56FE\u7247",i,4).option("-a, --angle <deg>","\u5E73\u94FA\u6C34\u5370\u7684\u503E\u659C\u89D2\u5EA6\uFF08\u5EA6\uFF0C0 = \u4E0D\u503E\u659C\uFF09",d).action(s)}
@@ -1 +1 @@
1
- import v from"sharp";import p from"path";import{randomUUID as T}from"crypto";import{readFile as L,rename as k,unlink as _,writeFile as B,mkdir as E}from"fs/promises";import{backupFile as U,replaceExt as O}from"#lib/utils/fs";export const OUTPUT_FORMATS=["jpeg","png","webp","avif","tiff","gif"];const W=["heif","svg"];export const EXT_BY_FORMAT={jpeg:".jpg",png:".png",webp:".webp",avif:".avif",tiff:".tiff",gif:".gif"},FALLBACK_OUTPUT={heif:"jpeg",svg:"png"},EXT_TO_INPUT_FORMAT={".jpg":"jpeg",".jpeg":"jpeg",".png":"png",".webp":"webp",".avif":"avif",".tiff":"tiff",".gif":"gif",".heic":"heif",".heif":"heif",".svg":"svg"};export function isOutputFormat(t){return OUTPUT_FORMATS.includes(t)}export function baseOutputFormat(t){return isOutputFormat(t)?t:FALLBACK_OUTPUT[t]}function A(t){const e=Math.round((100-t)/100*9);return t<=60?{compressionLevel:e,palette:!0}:{compressionLevel:e}}export function applyOutputFormat(t,e,o){switch(e){case"jpeg":return t.jpeg({quality:o,mozjpeg:!0});case"png":return t.png(A(o));case"webp":return t.webp({quality:o});case"avif":return t.avif({quality:o});case"tiff":return t.tiff({quality:o});case"gif":return t.gif()}}export async function compressOne(t,e,o,l){const r={file:t,output:t,format:"jpeg",beforeBytes:0,afterBytes:0,status:"failed"};try{const n=await L(t);r.beforeBytes=n.byteLength;const h=p.extname(t).toLowerCase(),m=EXT_TO_INPUT_FORMAT[h];if(m===void 0)throw new Error(`\u4E0D\u652F\u6301\u7684\u56FE\u7247\u6269\u5C55\u540D\uFF1A${h===""?"\u65E0\u6269\u5C55\u540D":h}`);const w=await v(n).metadata(),x=a=>{let s=v(n).rotate();return e.maxWidth!==void 0&&w.width!==void 0&&w.width>e.maxWidth&&(s=s.resize({width:e.maxWidth,withoutEnlargement:!0})),a.format==="jpeg"&&w.hasAlpha&&s.flatten({background:"#ffffff"}),a.lossless?s.webp({lossless:!0}):applyOutputFormat(s,a.format,e.quality)},c=baseOutputFormat(m),F=e.smart?[{format:c},{format:"webp"},...c==="png"?[{format:"webp",lossless:!0}]:[]]:[{format:e.format??c}];let i;for(const a of F){const{data:s}=await x(a).toBuffer({resolveWithObject:!0});(i===void 0||s.byteLength<i.data.byteLength)&&(i={format:a.format,data:s})}if(i===void 0)throw new Error("\u6CA1\u6709\u53EF\u7528\u7684\u8F93\u51FA\u5019\u9009");const d=i.format,g=d===m,u=(e.smart||g)&&i.data.byteLength>=n.byteLength,j=p.relative(o,t),y=u||g?j:O(j,EXT_BY_FORMAT[d]),f=e.outDir!==void 0?p.join(e.outDir,y):u||g?t:O(t,EXT_BY_FORMAT[d]);if(r.output=f,r.format=d,r.afterBytes=i.data.byteLength,u&&(r.afterBytes=n.byteLength,r.format=c),u&&e.outDir===void 0)return r.status="skipped",r;if(f!==t&&l.has(f))throw new Error(`\u8F93\u51FA\u8DEF\u5F84\u51B2\u7A81\uFF1A${p.basename(f)} \u5DF2\u88AB\u672C\u6279\u5176\u4ED6\u6587\u4EF6\u5360\u7528`);if(l.add(f),e.dry)return r.status=u?"skipped":"done",r;g&&e.outDir===void 0&&await U(o,t,e.backupDir),e.outDir!==void 0&&await E(p.dirname(f),{recursive:!0});const b=`${f}${T()}.tmp`;try{await B(b,u?n:i.data),await k(b,f)}catch(a){throw await _(b).catch(()=>{}),a}return r.status=u?"skipped":"done",r}catch(n){return r.error=n instanceof Error?n.message:String(n),r}}
1
+ import v from"sharp";import c from"path";import{randomUUID as L}from"crypto";import{readFile as T,rename as k,unlink as _,writeFile as E,mkdir as A}from"fs/promises";import{backupFile as B,replaceExt as j}from"#lib/utils/fs";export const OUTPUT_FORMATS=["jpeg","png","webp","avif","tiff","gif"];const N=["heif","svg"];export const EXT_BY_FORMAT={jpeg:".jpg",png:".png",webp:".webp",avif:".avif",tiff:".tiff",gif:".gif"},FALLBACK_OUTPUT={heif:"jpeg",svg:"png"},EXT_TO_INPUT_FORMAT={".jpg":"jpeg",".jpeg":"jpeg",".png":"png",".webp":"webp",".avif":"avif",".tiff":"tiff",".gif":"gif",".heic":"heif",".heif":"heif",".svg":"svg"};const Q=[{minQuality:80,colours:256,dither:1},{minQuality:70,colours:256,dither:.5},{minQuality:60,colours:16,dither:1},{minQuality:50,colours:16,dither:.5},{minQuality:40,colours:16,dither:0},{minQuality:10,colours:4,dither:0},{minQuality:1,colours:2,dither:0}];export function isOutputFormat(t){return OUTPUT_FORMATS.includes(t)}export function baseOutputFormat(t){return isOutputFormat(t)?t:FALLBACK_OUTPUT[t]}function U(t){if(t>80)return{compressionLevel:9,adaptiveFiltering:!0};const e=Q.find(i=>t>=i.minQuality);return e===void 0?{compressionLevel:9}:{compressionLevel:9,palette:!0,colours:e.colours,dither:e.dither}}export function applyOutputFormat(t,e,i){switch(e){case"jpeg":return t.jpeg({quality:i,mozjpeg:!0});case"png":return t.png(U(i));case"webp":return t.webp({quality:i});case"avif":return t.avif({quality:i});case"tiff":return t.tiff({quality:i});case"gif":return t.gif()}}export async function compressOne(t,e,i,b){const r={file:t,output:t,format:"jpeg",beforeBytes:0,afterBytes:0,status:"failed"};try{const n=await T(t);r.beforeBytes=n.byteLength;const m=c.extname(t).toLowerCase(),h=EXT_TO_INPUT_FORMAT[m];if(h===void 0)throw new Error(`\u4E0D\u652F\u6301\u7684\u56FE\u7247\u6269\u5C55\u540D\uFF1A${m===""?"\u65E0\u6269\u5C55\u540D":m}`);const l=await v(n).metadata(),F=a=>{let s=v(n).rotate();return e.maxWidth!==void 0&&l.width!==void 0&&l.width>e.maxWidth&&(s=s.resize({width:e.maxWidth,withoutEnlargement:!0})),a.format==="jpeg"&&l.hasAlpha&&s.flatten({background:"#ffffff"}),a.lossless?s.webp({lossless:!0}):applyOutputFormat(s,a.format,e.quality)},p=baseOutputFormat(h),O=e.smart?[{format:p},{format:"webp"},...p==="png"?[{format:"webp",lossless:!0}]:[]]:[{format:e.format??p}];let o;for(const a of O){const{data:s}=await F(a).toBuffer({resolveWithObject:!0});(o===void 0||s.byteLength<o.data.byteLength)&&(o={format:a.format,data:s})}if(o===void 0)throw new Error("\u6CA1\u6709\u53EF\u7528\u7684\u8F93\u51FA\u5019\u9009");const d=o.format,g=d===h,u=(e.smart||g)&&o.data.byteLength>=n.byteLength,y=c.relative(i,t),x=u||g?y:j(y,EXT_BY_FORMAT[d]),f=e.outDir!==void 0?c.join(e.outDir,x):u||g?t:j(t,EXT_BY_FORMAT[d]);if(r.output=f,r.format=d,r.afterBytes=o.data.byteLength,u&&(r.afterBytes=n.byteLength,r.format=p),u&&e.outDir===void 0)return r.status="skipped",r;if(f!==t&&b.has(f))throw new Error(`\u8F93\u51FA\u8DEF\u5F84\u51B2\u7A81\uFF1A${c.basename(f)} \u5DF2\u88AB\u672C\u6279\u5176\u4ED6\u6587\u4EF6\u5360\u7528`);if(b.add(f),e.dry)return r.status=u?"skipped":"done",r;g&&e.outDir===void 0&&await B(i,t,e.backupDir),e.outDir!==void 0&&await A(c.dirname(f),{recursive:!0});const w=`${f}${L()}.tmp`;try{await E(w,u?n:o.data),await k(w,f)}catch(a){throw await _(w).catch(()=>{}),a}return r.status=u?"skipped":"done",r}catch(n){return r.error=n instanceof Error?n.message:String(n),r}}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cantarella",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "批量压缩图片的命令行工具",
5
5
  "type": "module",
6
6
  "bin": {