getwebp 1.0.1 → 1.1.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 +212 -196
- package/dist/index.js +245 -89
- package/package.json +9 -4
package/README.md
CHANGED
|
@@ -1,318 +1,334 @@
|
|
|
1
1
|
# GetWebP CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Batch-convert JPG, PNG, BMP, WebP, HEIC, HEIF, and AVIF images to optimized WebP format from the command line.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
**Website:** [getwebp.com](https://getwebp.com) | **Pricing:** [getwebp.com/pricing](https://getwebp.com/pricing) | **Changelog:** [CHANGELOG.md](./CHANGELOG.md)
|
|
6
11
|
|
|
7
12
|
---
|
|
8
13
|
|
|
9
|
-
##
|
|
14
|
+
## Feature Highlights
|
|
15
|
+
|
|
16
|
+
- **Batch conversion** -- process entire directories of images in one command
|
|
17
|
+
- **Parallel processing** -- concurrent workers scale to your CPU cores (Starter/Pro)
|
|
18
|
+
- **Recursive scanning** -- traverse subdirectories with `-r`
|
|
19
|
+
- **CI/CD ready** -- structured `--json` output with machine-parseable error codes
|
|
20
|
+
- **Offline-first licensing** -- JWT-based activation works without a persistent connection
|
|
21
|
+
- **Cross-platform** -- native binaries for macOS (ARM + Intel), Linux x64, and Windows x64
|
|
22
|
+
|
|
23
|
+
---
|
|
10
24
|
|
|
11
|
-
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# 1. Download the binary for your platform (see Installation below)
|
|
29
|
+
# 2. Convert a single image
|
|
30
|
+
getwebp convert photo.jpg
|
|
31
|
+
|
|
32
|
+
# 3. Convert an entire directory
|
|
33
|
+
getwebp convert ./images -o ./output
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Output:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
✓ photo.jpg
|
|
40
|
+
Done: 1 succeeded, 0 failed
|
|
41
|
+
Avg saved: 34.2%
|
|
42
|
+
```
|
|
12
43
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
### Download Binary (Recommended)
|
|
49
|
+
|
|
50
|
+
Download the binary for your platform from the [Releases page](https://getwebp.com/download):
|
|
51
|
+
|
|
52
|
+
| Platform | Binary |
|
|
53
|
+
|---|---|
|
|
54
|
+
| macOS Apple Silicon (M1/M2/M3/M4) | `getwebp-macos-arm64` |
|
|
16
55
|
| macOS Intel | `getwebp-macos-x64` |
|
|
17
56
|
| Linux x64 | `getwebp-linux-x64` |
|
|
18
57
|
| Windows x64 | `getwebp-windows-x64.exe` |
|
|
19
58
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
### macOS / Linux 安装
|
|
59
|
+
#### macOS / Linux
|
|
23
60
|
|
|
24
61
|
```bash
|
|
25
|
-
#
|
|
26
|
-
mv getwebp-macos-arm64 getwebp
|
|
27
|
-
# mv getwebp-macos-x64 getwebp # Intel Mac
|
|
28
|
-
# mv getwebp-linux-x64 getwebp # Linux
|
|
29
|
-
|
|
62
|
+
# Rename and make executable
|
|
63
|
+
mv getwebp-macos-arm64 getwebp # adjust filename for your platform
|
|
30
64
|
chmod +x getwebp
|
|
31
|
-
|
|
32
|
-
# 移动到 PATH 目录(需要 sudo)
|
|
33
65
|
sudo mv getwebp /usr/local/bin/
|
|
34
66
|
|
|
35
|
-
#
|
|
67
|
+
# Verify
|
|
36
68
|
getwebp --version
|
|
37
69
|
```
|
|
38
70
|
|
|
39
|
-
|
|
71
|
+
#### Windows
|
|
40
72
|
|
|
41
|
-
|
|
73
|
+
Rename `getwebp-windows-x64.exe` to `getwebp.exe` and place it in a directory on your `PATH` (e.g. `C:\Windows\System32`), or add its directory to the system `PATH` environment variable.
|
|
42
74
|
|
|
43
|
-
### macOS Gatekeeper
|
|
75
|
+
### macOS Gatekeeper
|
|
44
76
|
|
|
45
|
-
macOS
|
|
77
|
+
macOS may block unsigned binaries. Remove the quarantine attribute:
|
|
46
78
|
|
|
47
79
|
```bash
|
|
48
80
|
xattr -cr getwebp
|
|
49
81
|
```
|
|
50
82
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
## 安全验证
|
|
54
|
-
|
|
55
|
-
每个 Release 都附带 SHA256 校验和文件(`*.sha256`),建议安装前验证:
|
|
83
|
+
### Verify Download (SHA256)
|
|
56
84
|
|
|
57
|
-
|
|
85
|
+
Each release includes SHA256 checksum files (`*.sha256`):
|
|
58
86
|
|
|
59
87
|
```bash
|
|
60
|
-
#
|
|
61
|
-
curl -LO https://github.com/getwebp/cli/releases/download/v2.1.0/getwebp-macos-arm64
|
|
62
|
-
curl -LO https://github.com/getwebp/cli/releases/download/v2.1.0/getwebp-macos-arm64.sha256
|
|
63
|
-
|
|
64
|
-
# 验证
|
|
88
|
+
# macOS / Linux
|
|
65
89
|
shasum -a 256 -c getwebp-macos-arm64.sha256
|
|
66
|
-
#
|
|
90
|
+
# Expected: getwebp-macos-arm64: OK
|
|
67
91
|
```
|
|
68
92
|
|
|
69
|
-
### Windows (PowerShell)
|
|
70
|
-
|
|
71
93
|
```powershell
|
|
72
|
-
#
|
|
73
|
-
Invoke-WebRequest -Uri "https://github.com/getwebp/cli/releases/download/v2.1.0/getwebp-win-x64.exe" -OutFile getwebp.exe
|
|
74
|
-
Invoke-WebRequest -Uri "https://github.com/getwebp/cli/releases/download/v2.1.0/getwebp-win-x64.exe.sha256" -OutFile getwebp.exe.sha256
|
|
75
|
-
|
|
76
|
-
# 验证
|
|
94
|
+
# Windows (PowerShell)
|
|
77
95
|
$expected = (Get-Content getwebp.exe.sha256).Split(' ')[0]
|
|
78
96
|
$actual = (Get-FileHash getwebp.exe -Algorithm SHA256).Hash.ToLower()
|
|
79
|
-
if ($expected -eq $actual) { Write-Host "
|
|
97
|
+
if ($expected -eq $actual) { Write-Host "Verified OK" } else { Write-Host "MISMATCH" }
|
|
80
98
|
```
|
|
81
99
|
|
|
82
100
|
---
|
|
83
101
|
|
|
84
|
-
##
|
|
102
|
+
## Commands
|
|
103
|
+
|
|
104
|
+
### Quick Reference
|
|
85
105
|
|
|
86
|
-
|
|
106
|
+
| Command | Description |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `getwebp convert <path>` | Convert images to WebP |
|
|
109
|
+
| `getwebp auth <key>` | Activate a license key |
|
|
110
|
+
| `getwebp status` | Show license and version info |
|
|
111
|
+
| `getwebp logout` | Unbind license from this device |
|
|
112
|
+
| `getwebp --version` | Print CLI version |
|
|
113
|
+
| `getwebp --help` | Show help |
|
|
114
|
+
|
|
115
|
+
### `getwebp convert`
|
|
116
|
+
|
|
117
|
+
Convert one or more images to WebP format. Original files are never modified or deleted.
|
|
87
118
|
|
|
88
119
|
```
|
|
89
120
|
getwebp convert [path] [options]
|
|
90
121
|
```
|
|
91
122
|
|
|
92
|
-
|
|
123
|
+
**Options:**
|
|
93
124
|
|
|
94
|
-
|
|
|
95
|
-
|
|
96
|
-
| `-i, --input <path>` |
|
|
97
|
-
| `-o, --output <path>` |
|
|
98
|
-
| `-q, --quality <
|
|
99
|
-
|
|
|
100
|
-
|
|
|
101
|
-
| `--dry-run` |
|
|
102
|
-
| `--skip-existing` |
|
|
103
|
-
| `--json` | JSON
|
|
125
|
+
| Option | Description | Default |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| `-i, --input <path>` | Input file or directory | -- |
|
|
128
|
+
| `-o, --output <path>` | Output directory | Same directory as source |
|
|
129
|
+
| `-q, --quality <n>` | WebP quality, 1--100 | `80` |
|
|
130
|
+
| `-r, --recursive` | Process subdirectories | off |
|
|
131
|
+
| `--concurrency <n>` | Parallel workers, max 32 (Starter/Pro) | CPU cores - 1 |
|
|
132
|
+
| `--dry-run` | Preview files without converting | off |
|
|
133
|
+
| `--skip-existing` | Skip if `.webp` already exists | off |
|
|
134
|
+
| `--json` | JSON output for CI / AI agents | off |
|
|
104
135
|
|
|
105
|
-
|
|
136
|
+
**Examples:**
|
|
106
137
|
|
|
107
138
|
```bash
|
|
108
|
-
#
|
|
139
|
+
# Convert a single image
|
|
109
140
|
getwebp convert photo.jpg
|
|
110
141
|
|
|
111
|
-
#
|
|
112
|
-
getwebp convert ./images
|
|
113
|
-
|
|
114
|
-
# 指定输入目录和输出目录
|
|
142
|
+
# Convert a directory with custom output
|
|
115
143
|
getwebp convert -i ./src/images -o ./dist/images
|
|
116
144
|
|
|
117
|
-
#
|
|
118
|
-
getwebp convert ./images -q
|
|
145
|
+
# High quality, skip already-converted files
|
|
146
|
+
getwebp convert ./images -q 95 --skip-existing
|
|
119
147
|
|
|
120
|
-
#
|
|
121
|
-
getwebp convert ./images
|
|
122
|
-
|
|
123
|
-
# 跳过已存在的 WebP 文件
|
|
124
|
-
getwebp convert ./images --skip-existing
|
|
125
|
-
|
|
126
|
-
# 递归处理子目录中的图片(Starter/Pro)
|
|
127
|
-
getwebp convert ./images --recursive
|
|
148
|
+
# Recursive scan of subdirectories
|
|
149
|
+
getwebp convert ./images -r
|
|
128
150
|
|
|
129
|
-
#
|
|
151
|
+
# Parallel processing with 8 workers (Starter/Pro)
|
|
130
152
|
getwebp convert ./images --concurrency 8
|
|
131
153
|
|
|
132
|
-
#
|
|
133
|
-
getwebp convert ./images --
|
|
154
|
+
# Preview what would be converted (no writes)
|
|
155
|
+
getwebp convert ./images --dry-run
|
|
134
156
|
|
|
135
|
-
#
|
|
136
|
-
getwebp convert
|
|
157
|
+
# JSON output for CI/CD pipelines
|
|
158
|
+
getwebp convert ./images --json
|
|
137
159
|
```
|
|
138
160
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
### `getwebp auth` — 激活 Pro 授权
|
|
142
|
-
|
|
143
|
-
```bash
|
|
144
|
-
getwebp auth <license-key>
|
|
145
|
-
```
|
|
161
|
+
### `getwebp auth`
|
|
146
162
|
|
|
147
|
-
|
|
163
|
+
Activate a license key to unlock Starter or Pro features.
|
|
148
164
|
|
|
149
165
|
```bash
|
|
150
|
-
# 使用许可证 Key 激活 Pro
|
|
151
166
|
getwebp auth XXXX-XXXX-XXXX-XXXX
|
|
167
|
+
```
|
|
152
168
|
|
|
153
|
-
|
|
169
|
+
```
|
|
170
|
+
Verifying license...
|
|
171
|
+
✓ Activated! Starter plan unlocked.
|
|
154
172
|
```
|
|
155
173
|
|
|
156
|
-
|
|
174
|
+
Purchase a license at [getwebp.com/pricing](https://getwebp.com/pricing).
|
|
157
175
|
|
|
158
|
-
|
|
176
|
+
### `getwebp status`
|
|
159
177
|
|
|
160
|
-
|
|
178
|
+
Display current license info, plan, expiry date, and device usage.
|
|
161
179
|
|
|
162
180
|
```bash
|
|
163
|
-
# 查看当前激活状态、许可证信息和到期时间
|
|
164
181
|
getwebp status
|
|
165
|
-
|
|
166
|
-
# 以 JSON 格式输出(适合脚本解析)
|
|
167
|
-
getwebp status --json
|
|
168
182
|
```
|
|
169
183
|
|
|
170
|
-
---
|
|
171
|
-
|
|
172
|
-
### `getwebp logout` — 退出登录
|
|
173
|
-
|
|
174
|
-
```bash
|
|
175
|
-
# 清除本地存储的许可证信息
|
|
176
|
-
getwebp logout
|
|
177
|
-
```
|
|
178
|
-
|
|
179
|
-
---
|
|
180
|
-
|
|
181
|
-
## JSON 输出格式参考
|
|
182
|
-
|
|
183
|
-
所有命令均支持 `--json` 参数,适用于 AI Agent 和 CI/CD 脚本。
|
|
184
|
-
|
|
185
|
-
**成功响应**(`success: true`):
|
|
186
|
-
```json
|
|
187
|
-
{ "success": true, "data": { ... } }
|
|
188
184
|
```
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
185
|
+
Version : 1.0.1
|
|
186
|
+
Mode : Starter
|
|
187
|
+
License : xxxx-xxxx-xxxx-A1B2
|
|
188
|
+
Expires : 2027-04-01
|
|
189
|
+
Devices : 1 / 3 used
|
|
193
190
|
```
|
|
194
191
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|------------|------|-----------|
|
|
199
|
-
| `missing_input` | 未指定输入路径 | — |
|
|
200
|
-
| `network_unreachable` | 无法连接服务器 | ✅ |
|
|
201
|
-
| `not_activated` | 未激活 Pro 许可证 | — |
|
|
202
|
-
| `device_not_found` | 设备未找到(可能已解绑) | — |
|
|
203
|
-
| `invalid_token` | Token 无效或已过期 | — |
|
|
204
|
-
| `unknown_error` | 未知错误 | ✅ |
|
|
205
|
-
|
|
206
|
-
**convert 成功示例:**
|
|
207
|
-
```json
|
|
208
|
-
{ "status": "success", "total": 3, "success": 3, "failed": 0, "results": [...] }
|
|
192
|
+
```bash
|
|
193
|
+
# Machine-readable output
|
|
194
|
+
getwebp status --json
|
|
209
195
|
```
|
|
210
196
|
|
|
211
|
-
**convert 错误示例(缺少路径):**
|
|
212
197
|
```json
|
|
213
|
-
{
|
|
198
|
+
{
|
|
199
|
+
"success": true,
|
|
200
|
+
"data": {
|
|
201
|
+
"version": "1.0.1",
|
|
202
|
+
"mode": "starter",
|
|
203
|
+
"licenseKeySuffix": "A1B2",
|
|
204
|
+
"expiresAt": "2027-04-01T00:00:00.000Z",
|
|
205
|
+
"devicesUsed": 1,
|
|
206
|
+
"devicesLimit": 3
|
|
207
|
+
}
|
|
208
|
+
}
|
|
214
209
|
```
|
|
215
210
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
---
|
|
219
|
-
|
|
220
|
-
## 退出码
|
|
211
|
+
### `getwebp logout`
|
|
221
212
|
|
|
222
|
-
|
|
223
|
-
|--------|------|
|
|
224
|
-
| `0` | 成功 |
|
|
225
|
-
| `1` | 失败(详见 stderr 或 JSON 的 `error` 字段) |
|
|
213
|
+
Unbind the license from this device, freeing a device slot. Requires network access.
|
|
226
214
|
|
|
227
|
-
|
|
215
|
+
```bash
|
|
216
|
+
getwebp logout # interactive confirmation
|
|
217
|
+
getwebp logout --force # skip confirmation (CI-safe)
|
|
218
|
+
```
|
|
228
219
|
|
|
229
220
|
---
|
|
230
221
|
|
|
231
|
-
##
|
|
222
|
+
## JSON Output
|
|
232
223
|
|
|
233
|
-
|
|
234
|
-
|------|:----:|:---:|
|
|
235
|
-
| 每次最多处理张数 | 10 张 | 无限制 |
|
|
236
|
-
| 处理速度 | 每张等待 3 秒 | 无限制 |
|
|
237
|
-
| 并发处理 | ❌ | ✅(最多 4 并发) |
|
|
238
|
-
| 递归目录(`-r`) | ❌ | ✅ |
|
|
239
|
-
| 自定义质量(`-q`) | ✅ | ✅ |
|
|
240
|
-
| JSON 输出(`--json`) | ✅ | ✅ |
|
|
241
|
-
|
|
242
|
-
升级 Pro:[https://getwebp.com/pricing](https://getwebp.com/pricing)
|
|
243
|
-
|
|
244
|
-
---
|
|
224
|
+
All commands support `--json` for structured output on stdout. Human-readable messages go to stderr.
|
|
245
225
|
|
|
246
|
-
|
|
226
|
+
**Success:**
|
|
247
227
|
|
|
248
|
-
|
|
228
|
+
```json
|
|
229
|
+
{
|
|
230
|
+
"success": true,
|
|
231
|
+
"status": "success",
|
|
232
|
+
"total": 3,
|
|
233
|
+
"successCount": 3,
|
|
234
|
+
"failedCount": 0,
|
|
235
|
+
"results": [
|
|
236
|
+
{ "file": "photo.jpg", "status": "success", "originalSize": 204800, "newSize": 133120, "savedRatio": 0.35, "saved": "35.0%" }
|
|
237
|
+
]
|
|
238
|
+
}
|
|
239
|
+
```
|
|
249
240
|
|
|
250
|
-
|
|
241
|
+
**Error:**
|
|
251
242
|
|
|
252
|
-
|
|
243
|
+
```json
|
|
244
|
+
{
|
|
245
|
+
"success": false,
|
|
246
|
+
"status": "error",
|
|
247
|
+
"error": "missing_input",
|
|
248
|
+
"message": "Please specify an input path"
|
|
249
|
+
}
|
|
250
|
+
```
|
|
253
251
|
|
|
254
|
-
**
|
|
252
|
+
**Error codes:**
|
|
255
253
|
|
|
256
|
-
|
|
254
|
+
| Code | Meaning | Retryable |
|
|
255
|
+
|---|---|---|
|
|
256
|
+
| `missing_input` | No input path specified | No |
|
|
257
|
+
| `network_unreachable` | Cannot reach API server | Yes |
|
|
258
|
+
| `not_activated` | No active license on device | No |
|
|
259
|
+
| `device_not_found` | Device already unbound | No |
|
|
260
|
+
| `invalid_token` | Token expired or revoked | No |
|
|
261
|
+
| `unknown_error` | Unclassified error | Yes |
|
|
257
262
|
|
|
258
|
-
|
|
259
|
-
xattr -cr getwebp
|
|
260
|
-
```
|
|
263
|
+
> **Note:** `getwebp logout --json` automatically skips the interactive confirmation prompt. You can also use `--force` to skip it in non-JSON mode.
|
|
261
264
|
|
|
262
265
|
---
|
|
263
266
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
A:在 [getwebp.com/pricing](https://getwebp.com/pricing) 购买许可证后,运行:
|
|
267
|
+
## Exit Codes
|
|
267
268
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
269
|
+
| Code | Meaning |
|
|
270
|
+
|---|---|
|
|
271
|
+
| `0` | All files processed successfully |
|
|
272
|
+
| `1` | General error |
|
|
273
|
+
| `2` | Partial failure (some files failed) |
|
|
274
|
+
| `3` | License / auth error |
|
|
275
|
+
| `4` | Network error |
|
|
271
276
|
|
|
272
|
-
|
|
277
|
+
See [docs/exit-codes.md](./docs/exit-codes.md) for detailed descriptions and retry guidance.
|
|
273
278
|
|
|
274
279
|
---
|
|
275
280
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
A:Free 模式每次最多转换 10 张图片,且每张图片处理前会等待 3 秒。不支持并发处理和递归子目录。升级 Pro 可解锁全部功能。
|
|
281
|
+
## Free vs Starter vs Pro
|
|
279
282
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
+
| Feature | Free | Starter | Pro |
|
|
284
|
+
|---|:---:|:---:|:---:|
|
|
285
|
+
| Files per run | 10 | Unlimited | Unlimited |
|
|
286
|
+
| Processing speed | 3s delay per file | Instant | Instant |
|
|
287
|
+
| Parallel workers | 1 (serial) | Auto (CPU cores - 1) | Auto (CPU cores - 1) |
|
|
288
|
+
| Custom `--concurrency` | -- | Up to 32 | Up to 32 |
|
|
289
|
+
| Recursive `-r` | Yes | Yes | Yes |
|
|
290
|
+
| Custom quality `-q` | Yes | Yes | Yes |
|
|
291
|
+
| JSON output `--json` | Yes | Yes | Yes |
|
|
292
|
+
| `--dry-run` / `--skip-existing` | Yes | Yes | Yes |
|
|
283
293
|
|
|
284
|
-
|
|
294
|
+
Upgrade at [getwebp.com/pricing](https://getwebp.com/pricing), then activate:
|
|
285
295
|
|
|
286
296
|
```bash
|
|
287
|
-
getwebp
|
|
297
|
+
getwebp auth <your-license-key>
|
|
288
298
|
```
|
|
289
299
|
|
|
290
|
-
配合 Pro 许可证,可以在 CI 环境中批量处理大量图片,无数量和速度限制。
|
|
291
|
-
|
|
292
|
-
---
|
|
293
|
-
|
|
294
|
-
**Q:支持退款吗?**
|
|
295
|
-
|
|
296
|
-
A:购买后如有问题,请联系 [getwebp.com](https://getwebp.com/contact) 客服,我们提供合理的退款支持。
|
|
297
|
-
|
|
298
300
|
---
|
|
299
301
|
|
|
300
|
-
|
|
302
|
+
## Supported Formats
|
|
301
303
|
|
|
302
|
-
|
|
304
|
+
| Input | Output |
|
|
305
|
+
|---|---|
|
|
306
|
+
| JPG / JPEG | WebP |
|
|
307
|
+
| PNG | WebP |
|
|
308
|
+
| BMP | WebP |
|
|
309
|
+
| WebP | WebP (re-encoded) |
|
|
310
|
+
| HEIC | WebP |
|
|
311
|
+
| HEIF | WebP |
|
|
312
|
+
| AVIF | WebP |
|
|
303
313
|
|
|
304
314
|
---
|
|
305
315
|
|
|
306
|
-
|
|
316
|
+
## Global Options
|
|
307
317
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
318
|
+
| Option | Description |
|
|
319
|
+
|---|---|
|
|
320
|
+
| `--verbose` | Enable verbose output |
|
|
321
|
+
| `--debug` | Enable debug output (implies `--verbose`) |
|
|
322
|
+
| `--json` | JSON output (available on all commands) |
|
|
323
|
+
| `--version`, `-v` | Print version |
|
|
324
|
+
| `--help` | Show help |
|
|
311
325
|
|
|
312
326
|
---
|
|
313
327
|
|
|
314
|
-
##
|
|
315
|
-
|
|
316
|
-
当前版本:**2.1.0**
|
|
328
|
+
## Links
|
|
317
329
|
|
|
318
|
-
|
|
330
|
+
- **Website:** [getwebp.com](https://getwebp.com)
|
|
331
|
+
- **Pricing:** [getwebp.com/pricing](https://getwebp.com/pricing)
|
|
332
|
+
- **Download:** [getwebp.com/download](https://getwebp.com/download)
|
|
333
|
+
- **Changelog:** [CHANGELOG.md](./CHANGELOG.md)
|
|
334
|
+
- **Dashboard:** [getwebp.com/dashboard](https://getwebp.com/dashboard) (manage devices and licenses)
|
package/dist/index.js
CHANGED
|
@@ -9,31 +9,130 @@ import jwt from "jsonwebtoken";
|
|
|
9
9
|
|
|
10
10
|
// src/core/config.ts
|
|
11
11
|
import Conf from "conf";
|
|
12
|
+
import crypto2 from "node:crypto";
|
|
13
|
+
import { unlinkSync } from "node:fs";
|
|
14
|
+
import os2 from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
// src/utils/debug.ts
|
|
18
|
+
var _verbose = false;
|
|
19
|
+
var _debug = false;
|
|
20
|
+
var _sink = (msg) => process.stderr.write(msg);
|
|
21
|
+
function setVerbose(v) {
|
|
22
|
+
_verbose = v;
|
|
23
|
+
}
|
|
24
|
+
function setDebug(v) {
|
|
25
|
+
_debug = v;
|
|
26
|
+
if (v) _verbose = true;
|
|
27
|
+
}
|
|
28
|
+
function debugLog(...args) {
|
|
29
|
+
if (_debug) _sink("[debug] " + args.join(" ") + "\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/core/machine-id.ts
|
|
33
|
+
import { execSync, exec } from "node:child_process";
|
|
12
34
|
import crypto from "node:crypto";
|
|
13
|
-
import { createRequire } from "node:module";
|
|
14
35
|
import os from "node:os";
|
|
15
|
-
|
|
16
|
-
|
|
36
|
+
function getRawId() {
|
|
37
|
+
const platform = process.platform;
|
|
38
|
+
try {
|
|
39
|
+
if (platform === "darwin") {
|
|
40
|
+
const out = execSync("ioreg -rd1 -c IOPlatformExpertDevice", { encoding: "utf8" });
|
|
41
|
+
const match = out.match(/IOPlatformUUID[^=]+=\s*"([^"]+)"/);
|
|
42
|
+
if (match) return match[1].toLowerCase();
|
|
43
|
+
} else if (platform === "linux") {
|
|
44
|
+
const out = execSync("( cat /var/lib/dbus/machine-id /etc/machine-id 2>/dev/null || hostname ) | head -n 1", {
|
|
45
|
+
encoding: "utf8",
|
|
46
|
+
shell: "/bin/sh"
|
|
47
|
+
});
|
|
48
|
+
return out.trim();
|
|
49
|
+
} else if (platform === "win32") {
|
|
50
|
+
const out = execSync(
|
|
51
|
+
"REG QUERY HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography /v MachineGuid",
|
|
52
|
+
{ encoding: "utf8" }
|
|
53
|
+
);
|
|
54
|
+
const match = out.match(/MachineGuid\s+REG_SZ\s+([^\r\n]+)/);
|
|
55
|
+
if (match) return match[1].trim();
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
}
|
|
59
|
+
return `${os.hostname()}:${os.userInfo().username}`;
|
|
60
|
+
}
|
|
61
|
+
function hashId(raw) {
|
|
62
|
+
return crypto.createHash("sha256").update(raw).digest("hex");
|
|
63
|
+
}
|
|
64
|
+
function machineIdSync() {
|
|
65
|
+
return hashId(getRawId());
|
|
66
|
+
}
|
|
67
|
+
function machineId() {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const platform = process.platform;
|
|
70
|
+
if (platform === "darwin") {
|
|
71
|
+
exec("ioreg -rd1 -c IOPlatformExpertDevice", (err, stdout) => {
|
|
72
|
+
if (!err) {
|
|
73
|
+
const match = stdout.match(/IOPlatformUUID[^=]+=\s*"([^"]+)"/);
|
|
74
|
+
if (match) return resolve(hashId(match[1].toLowerCase()));
|
|
75
|
+
}
|
|
76
|
+
resolve(hashId(getRawId()));
|
|
77
|
+
});
|
|
78
|
+
} else if (platform === "linux") {
|
|
79
|
+
exec("( cat /var/lib/dbus/machine-id /etc/machine-id 2>/dev/null || hostname ) | head -n 1", { shell: "/bin/sh" }, (err, stdout) => {
|
|
80
|
+
resolve(hashId(err ? getRawId() : stdout.trim()));
|
|
81
|
+
});
|
|
82
|
+
} else if (platform === "win32") {
|
|
83
|
+
exec("REG QUERY HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography /v MachineGuid", (err, stdout) => {
|
|
84
|
+
if (!err) {
|
|
85
|
+
const match = stdout.match(/MachineGuid\s+REG_SZ\s+([^\r\n]+)/);
|
|
86
|
+
if (match) return resolve(hashId(match[1].trim()));
|
|
87
|
+
}
|
|
88
|
+
resolve(hashId(getRawId()));
|
|
89
|
+
});
|
|
90
|
+
} else {
|
|
91
|
+
resolve(hashId(getRawId()));
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/core/config.ts
|
|
17
97
|
function getMachineKey() {
|
|
18
98
|
try {
|
|
19
99
|
const id = machineIdSync();
|
|
20
|
-
return
|
|
100
|
+
return crypto2.createHash("sha256").update(id).digest("hex");
|
|
21
101
|
} catch {
|
|
22
|
-
|
|
23
|
-
"warn: Could not read machine ID, falling back to hostname
|
|
102
|
+
debugLog(
|
|
103
|
+
"warn: Could not read machine ID, falling back to hostname.",
|
|
104
|
+
"Token may become invalid if hostname changes."
|
|
24
105
|
);
|
|
25
|
-
return
|
|
106
|
+
return crypto2.createHash("sha256").update(`${os2.hostname()}:${os2.userInfo().username}`).digest("hex");
|
|
26
107
|
}
|
|
27
108
|
}
|
|
28
|
-
var
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
109
|
+
var _store = null;
|
|
110
|
+
function getStore() {
|
|
111
|
+
if (!_store) {
|
|
112
|
+
const confOpts = {
|
|
113
|
+
projectName: "getwebp",
|
|
114
|
+
encryptionKey: getMachineKey(),
|
|
115
|
+
...process.env.GETWEBP_CONFIG_DIR ? { cwd: process.env.GETWEBP_CONFIG_DIR } : {}
|
|
116
|
+
};
|
|
117
|
+
try {
|
|
118
|
+
_store = new Conf(confOpts);
|
|
119
|
+
} catch {
|
|
120
|
+
debugLog("warn: Corrupt config file detected, clearing and reinitialising.");
|
|
121
|
+
const configDir = process.env.GETWEBP_CONFIG_DIR ?? (process.platform === "darwin" ? path.join(os2.homedir(), "Library", "Preferences", "getwebp-nodejs") : path.join(os2.homedir(), ".config", "getwebp"));
|
|
122
|
+
try {
|
|
123
|
+
unlinkSync(path.join(configDir, "config.json"));
|
|
124
|
+
} catch {
|
|
125
|
+
}
|
|
126
|
+
_store = new Conf(confOpts);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return _store;
|
|
130
|
+
}
|
|
33
131
|
function getConfig() {
|
|
34
|
-
return
|
|
132
|
+
return getStore().store;
|
|
35
133
|
}
|
|
36
134
|
function saveConfig(data) {
|
|
135
|
+
const store = getStore();
|
|
37
136
|
for (const [key, value] of Object.entries(data)) {
|
|
38
137
|
if (value === void 0) {
|
|
39
138
|
store.delete(key);
|
|
@@ -44,25 +143,23 @@ function saveConfig(data) {
|
|
|
44
143
|
}
|
|
45
144
|
|
|
46
145
|
// src/core/device.ts
|
|
47
|
-
import
|
|
48
|
-
import { createRequire as createRequire2 } from "node:module";
|
|
49
|
-
var _require2 = createRequire2(import.meta.url);
|
|
50
|
-
var { machineId } = _require2("node-machine-id");
|
|
146
|
+
import crypto3 from "node:crypto";
|
|
51
147
|
async function getDeviceId() {
|
|
52
148
|
const id = await machineId();
|
|
53
|
-
return
|
|
149
|
+
return crypto3.createHash("sha256").update(id).digest("hex");
|
|
54
150
|
}
|
|
55
151
|
|
|
56
152
|
// src/core/constants.ts
|
|
57
|
-
import
|
|
153
|
+
import os3 from "node:os";
|
|
58
154
|
var FREE_TIER = {
|
|
59
155
|
FILE_LIMIT: 10,
|
|
60
156
|
DELAY_MS: 3e3
|
|
61
157
|
};
|
|
62
158
|
var DEFAULTS = {
|
|
63
159
|
QUALITY: 80,
|
|
64
|
-
CONCURRENCY: Math.max(1,
|
|
160
|
+
CONCURRENCY: Math.max(1, os3.cpus().length - 1)
|
|
65
161
|
};
|
|
162
|
+
var AUTO_QUALITY_SENTINEL = -1;
|
|
66
163
|
var NETWORK = {
|
|
67
164
|
HEARTBEAT_TIMEOUT_MS: 3e3,
|
|
68
165
|
API_TIMEOUT_MS: 5e3,
|
|
@@ -130,7 +227,7 @@ async function activate(licenseKey) {
|
|
|
130
227
|
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
131
228
|
}
|
|
132
229
|
}
|
|
133
|
-
async function checkLicense() {
|
|
230
|
+
async function checkLicense(warn) {
|
|
134
231
|
const config = getConfig();
|
|
135
232
|
if (!config?.token) return { valid: false, plan: "free" };
|
|
136
233
|
try {
|
|
@@ -149,7 +246,8 @@ async function checkLicense() {
|
|
|
149
246
|
return { valid: false, plan: "free", expired: true };
|
|
150
247
|
}
|
|
151
248
|
if (err instanceof Error && err.message.includes("JWT_PUBLIC_KEY")) {
|
|
152
|
-
process.stderr.write(
|
|
249
|
+
const w = warn ?? ((msg) => process.stderr.write(msg + "\n"));
|
|
250
|
+
w("warn: JWT public key not found. License validation skipped.");
|
|
153
251
|
}
|
|
154
252
|
return { valid: false, plan: "free" };
|
|
155
253
|
}
|
|
@@ -216,44 +314,55 @@ function resolveExitCode(results) {
|
|
|
216
314
|
}
|
|
217
315
|
|
|
218
316
|
// src/core/processor.ts
|
|
219
|
-
import { encode as encodeWebp } from "@jsquash/webp";
|
|
220
|
-
import { unlinkSync, constants as fsConstants } from "node:fs";
|
|
317
|
+
import { encode as encodeWebp, decode as decodeWebp2 } from "@jsquash/webp";
|
|
318
|
+
import { unlinkSync as unlinkSync2, constants as fsConstants } from "node:fs";
|
|
221
319
|
import fs3 from "node:fs/promises";
|
|
222
|
-
import
|
|
320
|
+
import path4 from "node:path";
|
|
223
321
|
import pLimit from "p-limit";
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
var _verbose = false;
|
|
227
|
-
var _debug = false;
|
|
228
|
-
function setVerbose(v) {
|
|
229
|
-
_verbose = v;
|
|
230
|
-
}
|
|
231
|
-
function setDebug(v) {
|
|
232
|
-
_debug = v;
|
|
233
|
-
if (v) _verbose = true;
|
|
234
|
-
}
|
|
235
|
-
function debugLog(...args) {
|
|
236
|
-
if (_debug) process.stderr.write("[debug] " + args.join(" ") + "\n");
|
|
237
|
-
}
|
|
322
|
+
import { applySizeGuard } from "@getwebp/core/size-guard";
|
|
323
|
+
import { findOptimalQuality } from "@getwebp/core/auto-quality";
|
|
238
324
|
|
|
239
325
|
// src/core/codecs.ts
|
|
240
326
|
import { decode as decodeJpeg } from "@jsquash/jpeg";
|
|
241
327
|
import { decode as decodePng } from "@jsquash/png";
|
|
242
328
|
import { decode as decodeWebp } from "@jsquash/webp";
|
|
329
|
+
import { decode as decodeAvifWasm } from "@jsquash/avif";
|
|
243
330
|
import Bmp from "bmp-js";
|
|
244
331
|
import fs from "node:fs/promises";
|
|
245
|
-
import
|
|
332
|
+
import path2 from "node:path";
|
|
333
|
+
async function decodeHeic(buffer) {
|
|
334
|
+
const heicDecode = (await import("heic-decode")).default;
|
|
335
|
+
const { width, height, data } = await heicDecode({ buffer: new Uint8Array(buffer) });
|
|
336
|
+
return new ImageData(
|
|
337
|
+
new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),
|
|
338
|
+
width,
|
|
339
|
+
height
|
|
340
|
+
);
|
|
341
|
+
}
|
|
246
342
|
var decoders = {
|
|
247
343
|
".jpg": decodeJpeg,
|
|
248
344
|
".jpeg": decodeJpeg,
|
|
249
345
|
".png": decodePng,
|
|
250
|
-
".webp": decodeWebp
|
|
346
|
+
".webp": decodeWebp,
|
|
347
|
+
".avif": async (buf) => {
|
|
348
|
+
const result = await decodeAvifWasm(buf);
|
|
349
|
+
if (!result) throw new Error("AVIF decode returned null");
|
|
350
|
+
return result;
|
|
351
|
+
},
|
|
352
|
+
".heic": decodeHeic,
|
|
353
|
+
".heif": decodeHeic
|
|
251
354
|
};
|
|
252
355
|
var MAGIC_VALIDATORS = {
|
|
253
356
|
".jpg": (b) => b[0] === 255 && b[1] === 216 && b[2] === 255,
|
|
254
357
|
".jpeg": (b) => b[0] === 255 && b[1] === 216 && b[2] === 255,
|
|
255
358
|
".png": (b) => b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71,
|
|
256
359
|
".webp": (b) => b.length > 11 && b[8] === 87 && b[9] === 69 && b[10] === 66 && b[11] === 80,
|
|
360
|
+
".heic": (b) => b.length > 11 && b[4] === 102 && b[5] === 116 && b[6] === 121 && b[7] === 112 && (b[8] === 104 && b[9] === 101 && b[10] === 105 && b[11] === 99 || // "heic"
|
|
361
|
+
b[8] === 104 && b[9] === 101 && b[10] === 105 && b[11] === 102 || // "heif"
|
|
362
|
+
b[8] === 109 && b[9] === 105 && b[10] === 102 && b[11] === 49),
|
|
363
|
+
".heif": (b) => b.length > 11 && b[4] === 102 && b[5] === 116 && b[6] === 121 && b[7] === 112 && (b[8] === 104 && b[9] === 101 && b[10] === 105 && b[11] === 99 || b[8] === 104 && b[9] === 101 && b[10] === 105 && b[11] === 102 || b[8] === 109 && b[9] === 105 && b[10] === 102 && b[11] === 49),
|
|
364
|
+
".avif": (b) => b.length > 11 && b[4] === 102 && b[5] === 116 && b[6] === 121 && b[7] === 112 && (b[8] === 97 && b[9] === 118 && b[10] === 105 && b[11] === 102 || // "avif"
|
|
365
|
+
b[8] === 97 && b[9] === 118 && b[10] === 105 && b[11] === 115),
|
|
257
366
|
".bmp": (b) => b[0] === 66 && b[1] === 77
|
|
258
367
|
};
|
|
259
368
|
var BMP_MAX_DIMENSION = 65535;
|
|
@@ -276,7 +385,7 @@ async function decodeBmp(buffer) {
|
|
|
276
385
|
return new ImageData(new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), bmp.width, bmp.height);
|
|
277
386
|
}
|
|
278
387
|
async function decodeImage(filePath) {
|
|
279
|
-
const ext =
|
|
388
|
+
const ext = path2.extname(filePath).toLowerCase();
|
|
280
389
|
const buffer = await fs.readFile(filePath);
|
|
281
390
|
if (buffer.byteLength === 0) {
|
|
282
391
|
throw new Error(`File is empty (0 bytes): ${filePath}`);
|
|
@@ -307,13 +416,13 @@ async function decodeImage(filePath) {
|
|
|
307
416
|
|
|
308
417
|
// src/core/file-scanner.ts
|
|
309
418
|
import fs2 from "node:fs/promises";
|
|
310
|
-
import
|
|
311
|
-
var IMAGE_EXTS = /* @__PURE__ */ new Set([".jpg", ".jpeg", ".png", ".bmp", ".webp"]);
|
|
312
|
-
|
|
419
|
+
import path3 from "node:path";
|
|
420
|
+
var IMAGE_EXTS = /* @__PURE__ */ new Set([".jpg", ".jpeg", ".png", ".bmp", ".webp", ".heic", ".heif", ".avif"]);
|
|
421
|
+
var defaultWarn = (msg) => process.stderr.write(msg + "\n");
|
|
422
|
+
async function collectImageFiles(input, recursive, warn = defaultWarn) {
|
|
313
423
|
const lstat = await fs2.lstat(input);
|
|
314
424
|
if (lstat.isSymbolicLink()) {
|
|
315
|
-
|
|
316
|
-
`);
|
|
425
|
+
warn(`Warning: ${input} is a symlink, skipping`);
|
|
317
426
|
return [];
|
|
318
427
|
}
|
|
319
428
|
if (lstat.isFile()) return [input];
|
|
@@ -321,18 +430,17 @@ async function collectImageFiles(input, recursive) {
|
|
|
321
430
|
try {
|
|
322
431
|
entries = await fs2.readdir(input, { withFileTypes: true });
|
|
323
432
|
} catch {
|
|
324
|
-
|
|
325
|
-
`);
|
|
433
|
+
warn(`Warning: cannot read directory ${input}, skipping`);
|
|
326
434
|
return [];
|
|
327
435
|
}
|
|
328
436
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
329
437
|
const results = [];
|
|
330
438
|
const subdirPromises = [];
|
|
331
439
|
for (const entry of entries) {
|
|
332
|
-
const fullPath =
|
|
440
|
+
const fullPath = path3.join(input, entry.name);
|
|
333
441
|
if (entry.isDirectory() && recursive && !entry.isSymbolicLink()) {
|
|
334
|
-
subdirPromises.push(collectImageFiles(fullPath, recursive));
|
|
335
|
-
} else if (entry.isFile() && !entry.isSymbolicLink() && IMAGE_EXTS.has(
|
|
442
|
+
subdirPromises.push(collectImageFiles(fullPath, recursive, warn));
|
|
443
|
+
} else if (entry.isFile() && !entry.isSymbolicLink() && IMAGE_EXTS.has(path3.extname(entry.name).toLowerCase())) {
|
|
336
444
|
results.push(fullPath);
|
|
337
445
|
}
|
|
338
446
|
}
|
|
@@ -343,27 +451,34 @@ async function collectImageFiles(input, recursive) {
|
|
|
343
451
|
|
|
344
452
|
// src/core/wasm-init.ts
|
|
345
453
|
import { readFileSync } from "node:fs";
|
|
346
|
-
import { createRequire
|
|
454
|
+
import { createRequire } from "node:module";
|
|
347
455
|
import { init as initPngCodec } from "@jsquash/png/decode.js";
|
|
348
456
|
import { init as initJpegDec } from "@jsquash/jpeg/decode.js";
|
|
349
457
|
import { init as initWebpDec } from "@jsquash/webp/decode.js";
|
|
350
458
|
import { init as initWebpEnc } from "@jsquash/webp/encode.js";
|
|
351
|
-
|
|
459
|
+
import { init as initAvifDec } from "@jsquash/avif/decode.js";
|
|
460
|
+
var _require = createRequire(import.meta.url);
|
|
352
461
|
var initialized = false;
|
|
353
462
|
async function initWasm() {
|
|
354
463
|
if (initialized) return;
|
|
355
464
|
initialized = true;
|
|
356
|
-
const pngWasm = new WebAssembly.Module(readFileSync(
|
|
465
|
+
const pngWasm = new WebAssembly.Module(readFileSync(_require.resolve("@jsquash/png/codec/pkg/squoosh_png_bg.wasm")));
|
|
357
466
|
await initPngCodec(pngWasm);
|
|
358
|
-
const jpegWasm = new WebAssembly.Module(readFileSync(
|
|
467
|
+
const jpegWasm = new WebAssembly.Module(readFileSync(_require.resolve("@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm")));
|
|
359
468
|
await initJpegDec(jpegWasm);
|
|
360
|
-
const webpDecWasm = new WebAssembly.Module(readFileSync(
|
|
469
|
+
const webpDecWasm = new WebAssembly.Module(readFileSync(_require.resolve("@jsquash/webp/codec/dec/webp_dec.wasm")));
|
|
361
470
|
await initWebpDec(webpDecWasm);
|
|
362
|
-
const webpEncWasm = new WebAssembly.Module(readFileSync(
|
|
471
|
+
const webpEncWasm = new WebAssembly.Module(readFileSync(_require.resolve("@jsquash/webp/codec/enc/webp_enc.wasm")));
|
|
363
472
|
await initWebpEnc(webpEncWasm);
|
|
473
|
+
const avifDecWasm = new WebAssembly.Module(readFileSync(_require.resolve("@jsquash/avif/codec/dec/avif_dec.wasm")));
|
|
474
|
+
await initAvifDec(avifDecWasm);
|
|
364
475
|
}
|
|
365
476
|
|
|
366
477
|
// src/core/processor.ts
|
|
478
|
+
var HEIC_AVIF_EXTS = /* @__PURE__ */ new Set([".heic", ".heif", ".avif"]);
|
|
479
|
+
function isHeicOrAvif(filePath) {
|
|
480
|
+
return HEIC_AVIF_EXTS.has(path4.extname(filePath).toLowerCase());
|
|
481
|
+
}
|
|
367
482
|
async function processImages(options) {
|
|
368
483
|
const { input, output, quality, plan, out } = options;
|
|
369
484
|
const isFree = plan === "free";
|
|
@@ -383,7 +498,7 @@ async function processImages(options) {
|
|
|
383
498
|
}
|
|
384
499
|
await initWasm();
|
|
385
500
|
const recursive = options.recursive ?? false;
|
|
386
|
-
const files = await collectImageFiles(input, recursive);
|
|
501
|
+
const files = await collectImageFiles(input, recursive, (msg) => out.warn(msg));
|
|
387
502
|
debugLog("collected", files.length, "files");
|
|
388
503
|
if (isFree && files.length > 0) {
|
|
389
504
|
out.warn("Free plan: max 10 files, 3s delay between each.");
|
|
@@ -393,23 +508,23 @@ async function processImages(options) {
|
|
|
393
508
|
const skipped = isFree ? Math.max(0, files.length - FREE_TIER.FILE_LIMIT) : 0;
|
|
394
509
|
const outputPaths = /* @__PURE__ */ new Map();
|
|
395
510
|
for (const file of targets) {
|
|
396
|
-
const dir = output ??
|
|
397
|
-
const outName =
|
|
398
|
-
const outPath =
|
|
511
|
+
const dir = output ?? path4.dirname(file);
|
|
512
|
+
const outName = path4.basename(file, path4.extname(file)) + ".webp";
|
|
513
|
+
const outPath = path4.resolve(dir, outName);
|
|
399
514
|
const existing = outputPaths.get(outPath) ?? [];
|
|
400
515
|
existing.push(file);
|
|
401
516
|
outputPaths.set(outPath, existing);
|
|
402
517
|
}
|
|
403
518
|
for (const [outPath, sources] of outputPaths) {
|
|
404
519
|
if (sources.length > 1) {
|
|
405
|
-
out.warn(`Conflict: ${sources.join(", ")} all map to ${
|
|
520
|
+
out.warn(`Conflict: ${sources.join(", ")} all map to ${path4.basename(outPath)} \u2014 only the last processed will survive`);
|
|
406
521
|
}
|
|
407
522
|
}
|
|
408
523
|
if (!output && !dryRun) {
|
|
409
524
|
out.warn("No --output specified: converted files will be written next to source files.");
|
|
410
525
|
}
|
|
411
526
|
if (dryRun) {
|
|
412
|
-
const webpCount = targets.filter((f) =>
|
|
527
|
+
const webpCount = targets.filter((f) => path4.extname(f).toLowerCase() === ".webp").length;
|
|
413
528
|
const nonWebpCount = targets.length - webpCount;
|
|
414
529
|
out.warn("Dry run \u2014 no files will be converted:");
|
|
415
530
|
for (const file of targets) {
|
|
@@ -424,19 +539,18 @@ async function processImages(options) {
|
|
|
424
539
|
}
|
|
425
540
|
const results = [];
|
|
426
541
|
const writingFiles = /* @__PURE__ */ new Set();
|
|
542
|
+
const shouldRegisterSigint = options.registerSigint ?? true;
|
|
427
543
|
const sigintHandler = () => {
|
|
428
544
|
for (const f of writingFiles) {
|
|
429
545
|
try {
|
|
430
|
-
|
|
546
|
+
unlinkSync2(f);
|
|
431
547
|
} catch {
|
|
432
548
|
}
|
|
433
549
|
}
|
|
434
|
-
|
|
435
|
-
Interrupted. ${results.length} file(s) completed, ${writingFiles.size} in-progress cleaned up.
|
|
436
|
-
`);
|
|
550
|
+
out.warn(`Interrupted. ${results.length} file(s) completed, ${writingFiles.size} in-progress cleaned up.`);
|
|
437
551
|
process.exit(130);
|
|
438
552
|
};
|
|
439
|
-
process.once("SIGINT", sigintHandler);
|
|
553
|
+
if (shouldRegisterSigint) process.once("SIGINT", sigintHandler);
|
|
440
554
|
try {
|
|
441
555
|
if (isFree) {
|
|
442
556
|
for (let i = 0; i < targets.length; i++) {
|
|
@@ -449,7 +563,7 @@ Interrupted. ${results.length} file(s) completed, ${writingFiles.size} in-progre
|
|
|
449
563
|
await sleep(FREE_TIER.DELAY_MS);
|
|
450
564
|
}
|
|
451
565
|
debugLog("converting", file);
|
|
452
|
-
const result = await convertFile(file, output ??
|
|
566
|
+
const result = await convertFile(file, output ?? path4.dirname(file), quality, out, skipExisting, writingFiles);
|
|
453
567
|
debugLog("done", file, result.status);
|
|
454
568
|
results.push(result);
|
|
455
569
|
}
|
|
@@ -457,11 +571,13 @@ Interrupted. ${results.length} file(s) completed, ${writingFiles.size} in-progre
|
|
|
457
571
|
const MAX_CONCURRENCY = 32;
|
|
458
572
|
const rawConcurrency = Number(options.concurrency ?? DEFAULTS.CONCURRENCY);
|
|
459
573
|
const concurrency = Number.isNaN(rawConcurrency) || rawConcurrency < 1 ? DEFAULTS.CONCURRENCY : Math.min(Math.floor(rawConcurrency), MAX_CONCURRENCY);
|
|
460
|
-
const
|
|
461
|
-
const
|
|
574
|
+
const hasHeicOrAvif = targets.some(isHeicOrAvif);
|
|
575
|
+
const effectiveConcurrency = hasHeicOrAvif ? 1 : concurrency;
|
|
576
|
+
const limit = pLimit(effectiveConcurrency);
|
|
577
|
+
const uniqueDirs = new Set(targets.map((file) => output ?? path4.dirname(file)));
|
|
462
578
|
await Promise.all([...uniqueDirs].map((dir) => fs3.mkdir(dir, { recursive: true })));
|
|
463
579
|
const tasks = targets.map((file) => {
|
|
464
|
-
const outputDir = output ??
|
|
580
|
+
const outputDir = output ?? path4.dirname(file);
|
|
465
581
|
return limit(async () => {
|
|
466
582
|
debugLog("converting", file);
|
|
467
583
|
const result = await convertFile(file, outputDir, quality, out, skipExisting, writingFiles);
|
|
@@ -472,15 +588,15 @@ Interrupted. ${results.length} file(s) completed, ${writingFiles.size} in-progre
|
|
|
472
588
|
results.push(...await Promise.all(tasks));
|
|
473
589
|
}
|
|
474
590
|
} finally {
|
|
475
|
-
process.removeListener("SIGINT", sigintHandler);
|
|
591
|
+
if (shouldRegisterSigint) process.removeListener("SIGINT", sigintHandler);
|
|
476
592
|
}
|
|
477
593
|
out.summary(results, { skipped, totalFound: files.length, plan });
|
|
478
594
|
return results;
|
|
479
595
|
}
|
|
480
596
|
async function convertFile(filePath, outputDir, quality, out, skipExisting, writingFiles) {
|
|
481
|
-
const outName =
|
|
482
|
-
const outPath =
|
|
483
|
-
if (
|
|
597
|
+
const outName = path4.basename(filePath, path4.extname(filePath)) + ".webp";
|
|
598
|
+
const outPath = path4.join(outputDir, outName);
|
|
599
|
+
if (path4.extname(filePath).toLowerCase() === ".webp" && path4.normalize(outPath) === path4.normalize(filePath)) {
|
|
484
600
|
return { file: filePath, status: "skipped", reason: "existing" };
|
|
485
601
|
}
|
|
486
602
|
if (skipExisting) {
|
|
@@ -502,29 +618,54 @@ async function convertFile(filePath, outputDir, quality, out, skipExisting, writ
|
|
|
502
618
|
return { file: filePath, status: "error", error: `Decode failed: ${msg}` };
|
|
503
619
|
}
|
|
504
620
|
let webpBuffer;
|
|
621
|
+
let finalQuality;
|
|
622
|
+
let qualityMode;
|
|
623
|
+
let ssimScore;
|
|
505
624
|
try {
|
|
506
|
-
|
|
625
|
+
if (quality === AUTO_QUALITY_SENTINEL) {
|
|
626
|
+
const codec = {
|
|
627
|
+
encode: (img, q) => encodeWebp(img, { quality: q }),
|
|
628
|
+
decode: (buf) => decodeWebp2(buf)
|
|
629
|
+
};
|
|
630
|
+
const result = await findOptimalQuality(imageData, inputStat.size, codec);
|
|
631
|
+
finalQuality = result.quality;
|
|
632
|
+
ssimScore = result.ssim;
|
|
633
|
+
qualityMode = "auto";
|
|
634
|
+
webpBuffer = result.buffer ?? await encodeWebp(imageData, { quality: finalQuality });
|
|
635
|
+
} else {
|
|
636
|
+
finalQuality = quality;
|
|
637
|
+
qualityMode = "fixed";
|
|
638
|
+
webpBuffer = await encodeWebp(imageData, { quality });
|
|
639
|
+
}
|
|
507
640
|
} catch (err) {
|
|
508
641
|
spinner.fail();
|
|
509
642
|
const msg = err instanceof Error ? err.message : String(err);
|
|
510
643
|
return { file: filePath, status: "error", error: `Encode failed: ${msg}` };
|
|
511
644
|
}
|
|
645
|
+
const originalBuffer = (await fs3.readFile(filePath)).buffer;
|
|
646
|
+
const isWebP = path4.extname(filePath).toLowerCase() === ".webp";
|
|
647
|
+
const guard = applySizeGuard({ originalBuffer, encodedBuffer: webpBuffer, isWebP });
|
|
648
|
+
const finalBuffer = guard.buffer;
|
|
512
649
|
const tmpPath = outPath + ".tmp";
|
|
513
650
|
if (process.platform === "win32" && tmpPath.length >= 260) {
|
|
514
651
|
spinner.fail();
|
|
515
652
|
return { file: filePath, status: "error", error: `Output path too long for Windows (${tmpPath.length} chars, max 260). Use a shorter output directory or enable LongPathsEnabled in Windows registry.` };
|
|
516
653
|
}
|
|
517
654
|
writingFiles.add(tmpPath);
|
|
518
|
-
await fs3.writeFile(tmpPath, new Uint8Array(
|
|
655
|
+
await fs3.writeFile(tmpPath, new Uint8Array(finalBuffer));
|
|
519
656
|
await fs3.rename(tmpPath, outPath);
|
|
520
657
|
writingFiles.delete(tmpPath);
|
|
521
|
-
const
|
|
522
|
-
|
|
658
|
+
const newSize = finalBuffer.byteLength;
|
|
659
|
+
const savedRatio = 1 - newSize / inputStat.size;
|
|
660
|
+
spinner.succeed({ quality: finalQuality, qualityMode });
|
|
523
661
|
return {
|
|
524
662
|
file: filePath,
|
|
525
663
|
originalSize: inputStat.size,
|
|
526
|
-
newSize
|
|
664
|
+
newSize,
|
|
527
665
|
savedRatio,
|
|
666
|
+
quality: finalQuality,
|
|
667
|
+
qualityMode,
|
|
668
|
+
ssim: ssimScore,
|
|
528
669
|
status: "success"
|
|
529
670
|
};
|
|
530
671
|
} catch (err) {
|
|
@@ -545,7 +686,10 @@ var HumanOutput = class {
|
|
|
545
686
|
startFile(file) {
|
|
546
687
|
const spinner = ora(`Processing ${chalk.cyan(file)}`).start();
|
|
547
688
|
return {
|
|
548
|
-
succeed: () =>
|
|
689
|
+
succeed: (result) => {
|
|
690
|
+
const qualityInfo = result?.quality !== void 0 ? ` ${chalk.dim(`q=${result.quality}${result.qualityMode === "auto" ? " (auto)" : ""}`)}` : "";
|
|
691
|
+
spinner.succeed(chalk.green(`\u2713 ${file}`) + qualityInfo);
|
|
692
|
+
},
|
|
549
693
|
fail: () => spinner.fail(chalk.red(`\u2717 ${file}`))
|
|
550
694
|
};
|
|
551
695
|
}
|
|
@@ -669,7 +813,7 @@ var earlyFlags = {
|
|
|
669
813
|
|
|
670
814
|
// src/commands/convert.ts
|
|
671
815
|
function convertCommand(cli2) {
|
|
672
|
-
cli2.command("[path]", "Convert images
|
|
816
|
+
cli2.command("[path]", "Convert images to WebP format. Original files are never modified or deleted.").option("-i, --input <path>", "Input file or directory path (e.g. ./images)").option("-o, --output <path>", "Output directory for converted files (default: write .webp next to source files)").option("-q, --quality <number>", "WebP quality 1\u2013100 (default: 80)", { default: void 0 }).option("--json", "Output results as JSON \u2014 useful for CI pipelines and AI agents").option("--concurrency <number>", "Number of parallel workers (default: auto, paid plans only)", { default: void 0 }).option("-r, --recursive", "Recursively process images in subdirectories").option("--dry-run", "Preview which files would be converted without actually converting them").option("--skip-existing", "Skip conversion if a .webp file already exists at the output path").option("--no-auto-quality", "Disable auto quality, use fixed quality=80").action((argPath, options) => {
|
|
673
817
|
runAction(async () => {
|
|
674
818
|
const targetPath = argPath || options.input;
|
|
675
819
|
if (!targetPath) {
|
|
@@ -684,8 +828,20 @@ function convertCommand(cli2) {
|
|
|
684
828
|
}
|
|
685
829
|
const out = createOutput(options.json ?? false);
|
|
686
830
|
const licenseStatus = await checkLicense();
|
|
687
|
-
|
|
688
|
-
|
|
831
|
+
if (!licenseStatus.valid && licenseStatus.expired) {
|
|
832
|
+
out.warn("Your license has expired. Please renew to continue using Pro features.");
|
|
833
|
+
} else if (!licenseStatus.valid && !licenseStatus.expired) {
|
|
834
|
+
out.warn("Free mode: conversion is limited. Activate a license for full access.");
|
|
835
|
+
}
|
|
836
|
+
let quality;
|
|
837
|
+
if (options.quality !== void 0) {
|
|
838
|
+
const rawQuality = Number(options.quality);
|
|
839
|
+
quality = Number.isNaN(rawQuality) ? 80 : Math.max(1, Math.min(100, rawQuality));
|
|
840
|
+
} else if (options.autoQuality === false) {
|
|
841
|
+
quality = 80;
|
|
842
|
+
} else {
|
|
843
|
+
quality = AUTO_QUALITY_SENTINEL;
|
|
844
|
+
}
|
|
689
845
|
const results = await processImages({
|
|
690
846
|
input: targetPath,
|
|
691
847
|
output: options.output,
|
|
@@ -738,7 +894,7 @@ function authCommand(cli2) {
|
|
|
738
894
|
}
|
|
739
895
|
|
|
740
896
|
// src/version.ts
|
|
741
|
-
var VERSION = true ? "1.0
|
|
897
|
+
var VERSION = true ? "1.1.0" : await getDevVersion();
|
|
742
898
|
|
|
743
899
|
// src/commands/status.ts
|
|
744
900
|
function statusCommand(cli2) {
|
|
@@ -857,7 +1013,7 @@ function logoutCommand(cli2) {
|
|
|
857
1013
|
);
|
|
858
1014
|
await safeExit(1);
|
|
859
1015
|
} else if (result.error === "not_activated") {
|
|
860
|
-
process.stderr.write("No active license found on this device.\n");
|
|
1016
|
+
process.stderr.write("No active Pro license found on this device.\n");
|
|
861
1017
|
await safeExit(1);
|
|
862
1018
|
} else {
|
|
863
1019
|
process.stderr.write(`\u2717 Logout failed: ${result.error}
|
|
@@ -882,7 +1038,7 @@ function errorMessage(error) {
|
|
|
882
1038
|
case "network_unreachable":
|
|
883
1039
|
return "Cannot reach server. Retry later or unbind via dashboard.";
|
|
884
1040
|
case "not_activated":
|
|
885
|
-
return "No active license on this device.";
|
|
1041
|
+
return "No active Pro license on this device.";
|
|
886
1042
|
case "device_not_found":
|
|
887
1043
|
return "Device not found. It may have already been unbound.";
|
|
888
1044
|
case "invalid_token":
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "getwebp",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "Convert images to WebP
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Convert images to WebP from the command line",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"getwebp": "dist/index.js"
|
|
@@ -14,12 +14,15 @@
|
|
|
14
14
|
},
|
|
15
15
|
"scripts": {
|
|
16
16
|
"dev": "bun run src/index.ts",
|
|
17
|
-
"build": "CLI_VERSION=$(node -p \"require('./package.json').version\") bun build src/index.ts --compile --minify --define \"CLI_VERSION='$CLI_VERSION'\" --outfile release/getwebp",
|
|
17
|
+
"build": "export CLI_VERSION=$(node -p \"require('./package.json').version\") && bun build src/index.ts --compile --minify --define \"CLI_VERSION='$CLI_VERSION'\" --outfile release/getwebp",
|
|
18
18
|
"typecheck": "tsc --noEmit",
|
|
19
19
|
"test": "bun test --timeout 60000 tests/isolated/ && bun test --timeout 60000 tests/",
|
|
20
|
+
"test:e2e": "bun test --timeout 60000 tests/e2e/",
|
|
21
|
+
"test:e2e:binary": "USE_BINARY=1 bun test --timeout 60000 tests/e2e/",
|
|
20
22
|
"test:watch": "bun test --timeout 60000 --watch"
|
|
21
23
|
},
|
|
22
24
|
"dependencies": {
|
|
25
|
+
"@getwebp/core": "*",
|
|
23
26
|
"@jsquash/jpeg": "^1.5.0",
|
|
24
27
|
"@jsquash/png": "^3.1.0",
|
|
25
28
|
"@jsquash/webp": "^1.4.0",
|
|
@@ -30,7 +33,9 @@
|
|
|
30
33
|
"jsonwebtoken": "^9.0.2",
|
|
31
34
|
"node-machine-id": "^1.1.12",
|
|
32
35
|
"ora": "^8.1.0",
|
|
33
|
-
"p-limit": "^6.2.0"
|
|
36
|
+
"p-limit": "^6.2.0",
|
|
37
|
+
"@jsquash/avif": "^2.1.1",
|
|
38
|
+
"heic-decode": "^2.1.0"
|
|
34
39
|
},
|
|
35
40
|
"devDependencies": {
|
|
36
41
|
"@types/jsonwebtoken": "^9.0.7",
|