ss-tools-duck 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,317 @@
1
+ # SS_tools Duck — Node.js 隐写工具库
2
+
3
+ 将任意数据(文本、图片、视频、文件)隐藏在一张可爱的鸭子图片中,或从鸭子图中还原数据。
4
+
5
+ 完全对应 ComfyUI 插件 [SS_tools](../) 的 Python 实现,编码格式互相兼容。
6
+
7
+ ---
8
+
9
+ ## 目录
10
+
11
+ - [快速开始](#快速开始)
12
+ - [安装依赖](#安装依赖)
13
+ - [命令行 CLI](#命令行-cli)
14
+ - [编程 API](#编程-api)
15
+ - [encode — 编码](#encode--编码)
16
+ - [decode — 解码](#decode--解码)
17
+ - [video — 视频合成](#video--视频合成需-ffmpeg)
18
+ - [底层工具](#底层工具)
19
+ - [与 Python 版互通](#与-python-版互通)
20
+ - [参数说明](#参数说明)
21
+
22
+ ---
23
+
24
+ ## 快速开始
25
+
26
+ ```bash
27
+ # 隐藏文字
28
+ node duck-cli.js encode text secret.png --text="你好,世界" --password=mypass
29
+
30
+ # 还原文字
31
+ node duck-cli.js decode secret.png --password=mypass
32
+ ```
33
+
34
+ ---
35
+
36
+ ## 安装依赖
37
+
38
+ ```bash
39
+ cd nodejs
40
+ npm install
41
+ ```
42
+
43
+ **依赖项:**
44
+
45
+ | 包 | 用途 |
46
+ |---|---|
47
+ | `sharp` | PNG 像素读写 |
48
+ | `fluent-ffmpeg` | 视频合成 / 解帧(需系统 ffmpeg) |
49
+ | `glob` | CLI frames 命令路径展开 |
50
+
51
+ > **视频功能**需要在系统中安装 [ffmpeg](https://ffmpeg.org/download.html) 并加入 `PATH`。
52
+ > 运行 `node duck-cli.js check-ffmpeg` 验证是否可用。
53
+
54
+ ---
55
+
56
+ ## 命令行 CLI
57
+
58
+ ### encode text — 隐藏文本
59
+
60
+ ```bash
61
+ node duck-cli.js encode text <输出.png> --text="内容" [--password=密码] [--title=标题] [--compress=2|6|8]
62
+
63
+ # 从文件读取文本
64
+ node duck-cli.js encode text out.png --text-file=secret.txt --password=abc
65
+ ```
66
+
67
+ ### encode image — 隐藏图片
68
+
69
+ ```bash
70
+ node duck-cli.js encode image <输出.png> --input=<图片路径> [--password=密码] [--title=标题] [--compress=2|6|8]
71
+ ```
72
+
73
+ ### encode bytes — 隐藏任意文件
74
+
75
+ ```bash
76
+ node duck-cli.js encode bytes <输出.png> --input=<文件路径> [--ext=扩展名] [--password=密码]
77
+
78
+ # 隐藏 PDF
79
+ node duck-cli.js encode bytes out.png --input=report.pdf
80
+ ```
81
+
82
+ ### encode video — 隐藏 MP4
83
+
84
+ ```bash
85
+ node duck-cli.js encode video <输出.png> --input=<视频.mp4> [--password=密码] [--title=标题]
86
+ ```
87
+
88
+ ### encode frames — 帧序列 → 视频 → 鸭子图(需 ffmpeg)
89
+
90
+ ```bash
91
+ node duck-cli.js encode frames <输出.png> --frames="./frames/*.png" --fps=30 [--audio=bgm.mp3]
92
+ ```
93
+
94
+ ### decode — 解码还原
95
+
96
+ ```bash
97
+ node duck-cli.js decode <输入.png> [--password=密码] [--output=输出目录] [--json]
98
+
99
+ # --json 只输出元数据,不写文件
100
+ node duck-cli.js decode secret.png --password=abc --json
101
+ ```
102
+
103
+ 输出示例:
104
+ ```json
105
+ {
106
+ "ext": "txt",
107
+ "filePath": "/path/to/duck_recovered.txt",
108
+ "text": "你好,世界",
109
+ "dataBytes": 15
110
+ }
111
+ ```
112
+
113
+ ### check-ffmpeg — 检查 ffmpeg
114
+
115
+ ```bash
116
+ node duck-cli.js check-ffmpeg
117
+ ```
118
+
119
+ ---
120
+
121
+ ## 编程 API
122
+
123
+ ```js
124
+ const duck = require('./index'); // 或 require('./index.js')
125
+ ```
126
+
127
+ ### encode — 编码
128
+
129
+ #### `encodeText(opts)` → `Promise<{ imageBuffer, filePath? }>`
130
+
131
+ ```js
132
+ const { imageBuffer } = await duck.encodeText({
133
+ text: '要隐藏的文字',
134
+ password: 'my_password', // 省略 = 不加密
135
+ title: '鸭子标题', // 显示在图片右下角
136
+ compress: 2, // 2 / 6 / 8,默认 2
137
+ outputPath: 'out.png', // 省略 = 不写文件,只返回 Buffer
138
+ });
139
+ ```
140
+
141
+ #### `encodeImage(opts)` → `Promise<EncodeResult>`
142
+
143
+ ```js
144
+ const imageData = fs.readFileSync('photo.jpg');
145
+ const { filePath } = await duck.encodeImage({
146
+ image: imageData,
147
+ compress: 6,
148
+ outputPath: 'duck_out.png',
149
+ });
150
+ ```
151
+
152
+ #### `encodeBytes(opts)` → `Promise<EncodeResult>`
153
+
154
+ ```js
155
+ const pdfBytes = fs.readFileSync('report.pdf');
156
+ await duck.encodeBytes({
157
+ rawBytes: pdfBytes,
158
+ ext: 'pdf',
159
+ outputPath: 'duck_pdf.png',
160
+ });
161
+ ```
162
+
163
+ #### `encodeMp4(opts)` → `Promise<EncodeResult>`
164
+
165
+ ```js
166
+ const mp4Buffer = fs.readFileSync('video.mp4');
167
+ await duck.encodeMp4({
168
+ mp4Input: mp4Buffer, // Buffer 或文件路径字符串
169
+ password: 'secret',
170
+ outputPath: 'duck_video.png',
171
+ });
172
+ ```
173
+
174
+ #### `encodeImageSequence(opts)` → `Promise<{ imageBuffers, filePaths? }>`
175
+
176
+ ```js
177
+ const frames = [buf1, buf2, buf3]; // 每帧一个 PNG/JPEG Buffer
178
+ const { filePaths } = await duck.encodeImageSequence({
179
+ images: frames,
180
+ outputDir: './output',
181
+ });
182
+ // → output/duck_000.png, duck_001.png, duck_002.png
183
+ ```
184
+
185
+ ---
186
+
187
+ ### decode — 解码
188
+
189
+ #### `decodeDuckImage(opts)` → `Promise<DecodeResult>`
190
+
191
+ ```js
192
+ const result = await duck.decodeDuckImage({
193
+ duckImage: fs.readFileSync('secret.png'), // Buffer 或文件路径
194
+ password: 'my_password',
195
+ outputDir: './recovered',
196
+ });
197
+
198
+ console.log(result.ext); // "txt" | "png" | "mp4" | "pdf" …
199
+ console.log(result.text); // 若是文本则已解码为字符串
200
+ console.log(result.imageBuffer); // 若是图片
201
+ console.log(result.mp4Buffer); // 若是视频
202
+ console.log(result.filePath); // 还原文件的路径
203
+ ```
204
+
205
+ #### 便捷接口
206
+
207
+ ```js
208
+ // 从文件路径
209
+ const result = await duck.decodeFromFile('secret.png', 'password', './out');
210
+
211
+ // 从 Buffer
212
+ const result = await duck.decodeFromBuffer(pngBuffer, 'password', './out');
213
+ ```
214
+
215
+ ---
216
+
217
+ ### video — 视频合成(需 ffmpeg)
218
+
219
+ #### `imagesToMp4(opts)` → `Promise<Buffer>`
220
+
221
+ ```js
222
+ const frames = pngBuffers; // PNG Buffer 数组
223
+ const mp4Buf = await duck.imagesToMp4({
224
+ frames,
225
+ fps: 30,
226
+ audioPath: 'bgm.mp3', // 可选
227
+ outputPath: 'output.mp4', // 可选
228
+ });
229
+ ```
230
+
231
+ #### `mp4ToFrames(opts)` → `Promise<{ frames, fps }>`
232
+
233
+ ```js
234
+ const { frames, fps } = await duck.mp4ToFrames({
235
+ mp4Input: fs.readFileSync('video.mp4'),
236
+ fps: null, // null = 原始帧率
237
+ });
238
+ ```
239
+
240
+ #### `encodeVideoFrames(opts)` → `Promise<EncodeResult>`
241
+
242
+ 帧序列一步编码为鸭子图:
243
+
244
+ ```js
245
+ await duck.encodeVideoFrames({
246
+ frames: pngBuffers,
247
+ fps: 24,
248
+ audioPath: 'bgm.wav',
249
+ password: 'secret',
250
+ outputPath: 'duck_video.png',
251
+ });
252
+ ```
253
+
254
+ ---
255
+
256
+ ### 底层工具
257
+
258
+ ```js
259
+ const {
260
+ buildFileHeader,
261
+ parseHeader,
262
+ exportDuckPayload,
263
+ embedPayloadLSB,
264
+ extractPayloadWithK,
265
+ bytesToBinaryImage,
266
+ binaryImageToBytes,
267
+ buildDuckImageBuffer,
268
+ requiredCanvasSize,
269
+ } = require('./duck_payload_exporter');
270
+ ```
271
+
272
+ 详见 [index.d.ts](./index.d.ts) 获取完整类型定义。
273
+
274
+ ---
275
+
276
+ ## 与 Python 版互通
277
+
278
+ Node.js 和 Python 版本使用**完全相同的二进制格式**,可以互相编解码:
279
+
280
+ | Python(ComfyUI 节点)编码 → Node.js 解码 | ✅ 支持 |
281
+ |---|---|
282
+ | Node.js 编码 → Python(ComfyUI 节点)解码 | ✅ 支持 |
283
+
284
+ 对齐细节:
285
+
286
+ | 常量 / 算法 | 值 |
287
+ |---|---|
288
+ | 水印跳过区域 | 左上角 `40% 宽 × 8% 高` |
289
+ | bit 顺序 | 大端(高位优先)—— 对应 `np.unpackbits(bitorder="big")` |
290
+ | 文件头格式 | `[has_pwd:1][pwdHash:32][salt:16][extLen:1][ext][dataLen:4 BE][data]` |
291
+ | 长度前缀 | 4 字节大端整数,存储在图像 LSB 前 |
292
+ | XOR 密钥流 | `SHA-256(password + counter)` 迭代生成 |
293
+
294
+ ---
295
+
296
+ ## 参数说明
297
+
298
+ ### `compress` 参数
299
+
300
+ | 值 | 每通道隐藏 bit 数 | 视觉失真 | 最大容量(512×512 图) |
301
+ |---|---|---|---|
302
+ | `2` | 2 bit | 极低,肉眼不可见 | ~65 KB |
303
+ | `6` | 6 bit | 低 | ~196 KB |
304
+ | `8` | 8 bit | 中(颜色偏差明显) | ~262 KB |
305
+
306
+ ### `password` 参数
307
+
308
+ - 空字符串 `""` 或省略:**不加密**,任何人可解码
309
+ - 非空字符串:使用 SHA-256 + 随机 salt 的 XOR 流加密
310
+
311
+ ### 文件大小限制
312
+
313
+ 实际可隐藏的数据量取决于鸭子图的分辨率和 `compress` 值:
314
+
315
+ $$容量(bytes) = \frac{(宽 × 高 - 水印区像素数) × 3 × k}{8} - 文件头长度$$
316
+
317
+ 其中 $k$ = compress 值(2/6/8),水印区为左上 40%×8% 区域。
package/duck-cli.js ADDED
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * duck-cli.js — SS_tools Duck 隐写命令行工具
4
+ *
5
+ * 用法:
6
+ * node duck-cli.js encode text [选项] <输出路径>
7
+ * node duck-cli.js encode image [选项] <输出路径>
8
+ * node duck-cli.js encode bytes [选项] <输出路径>
9
+ * node duck-cli.js encode video [选项] <输出路径> (需系统 ffmpeg)
10
+ * node duck-cli.js encode frames [选项] <输出路径> (需系统 ffmpeg)
11
+ * node duck-cli.js decode [选项] <输入路径>
12
+ * node duck-cli.js check-ffmpeg
13
+ * node duck-cli.js help
14
+ *
15
+ * 完整帮助:node duck-cli.js help
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+
23
+ // ─── 彩色输出 ─────────────────────────────────────────────────────────────────
24
+
25
+ const C = {
26
+ reset: '\x1b[0m',
27
+ bold: '\x1b[1m',
28
+ green: '\x1b[32m',
29
+ yellow: '\x1b[33m',
30
+ red: '\x1b[31m',
31
+ cyan: '\x1b[36m',
32
+ gray: '\x1b[90m',
33
+ };
34
+
35
+ function ok(msg) { console.log(`${C.green}✅ ${msg}${C.reset}`); }
36
+ function err(msg) { console.error(`${C.red}❌ ${msg}${C.reset}`); }
37
+ function info(msg) { console.log(`${C.cyan}ℹ ${msg}${C.reset}`); }
38
+ function warn(msg) { console.warn(`${C.yellow}⚠ ${msg}${C.reset}`); }
39
+
40
+ // ─── 参数解析 ─────────────────────────────────────────────────────────────────
41
+
42
+ function parseArgs(argv) {
43
+ const flags = {};
44
+ const positional = [];
45
+
46
+ for (let i = 0; i < argv.length; i++) {
47
+ const a = argv[i];
48
+ if (a.startsWith('--')) {
49
+ const eq = a.indexOf('=');
50
+ if (eq !== -1) {
51
+ flags[a.slice(2, eq)] = a.slice(eq + 1);
52
+ } else {
53
+ // 下一个 token 若不以 -- 开头则是值,否则是布尔
54
+ const next = argv[i + 1];
55
+ if (next && !next.startsWith('--')) {
56
+ flags[a.slice(2)] = next;
57
+ i++;
58
+ } else {
59
+ flags[a.slice(2)] = true;
60
+ }
61
+ }
62
+ } else {
63
+ positional.push(a);
64
+ }
65
+ }
66
+
67
+ return { flags, positional };
68
+ }
69
+
70
+ // ─── 帮助文本 ─────────────────────────────────────────────────────────────────
71
+
72
+ const HELP_TEXT = `
73
+ ${C.bold}SS_tools Duck 隐写 CLI${C.reset}
74
+
75
+ ${C.bold}用法${C.reset}
76
+ node duck-cli.js <命令> [子命令] [选项] [路径]
77
+
78
+ ${C.bold}命令${C.reset}
79
+
80
+ ${C.cyan}encode text${C.reset} <输出.png>
81
+ 将文本隐藏到鸭子图中
82
+ 选项:
83
+ --text=<内容> 要隐藏的文本(与 --text-file 二选一)
84
+ --text-file=<路径> 从文件读取文本
85
+ --password=<密码> 加密密码(留空=不加密)
86
+ --title=<标题> 鸭子图标题
87
+ --compress=<2|6|8> 压缩比(默认:2)
88
+
89
+ ${C.cyan}encode image${C.reset} <输出.png>
90
+ 将图片隐藏到鸭子图中
91
+ 选项:
92
+ --input=<路径> 输入图片路径(必填)
93
+ --password=<密码>
94
+ --title=<标题>
95
+ --compress=<2|6|8>
96
+
97
+ ${C.cyan}encode bytes${C.reset} <输出.png>
98
+ 将任意文件隐藏到鸭子图中
99
+ 选项:
100
+ --input=<路径> 输入文件路径(必填)
101
+ --ext=<扩展名> 文件扩展名(如 pdf、zip;默认取输入文件扩展名)
102
+ --password=<密码>
103
+ --title=<标题>
104
+ --compress=<2|6|8>
105
+
106
+ ${C.cyan}encode video${C.reset} <输出.png>
107
+ 将 MP4 视频隐藏到鸭子图中(binpng 格式)
108
+ 选项:
109
+ --input=<路径> 输入 MP4 文件路径(必填)
110
+ --password=<密码>
111
+ --title=<标题>
112
+ --compress=<2|6|8>
113
+
114
+ ${C.cyan}encode frames${C.reset} <输出.png>
115
+ 将图片帧序列合成为 MP4,再隐藏到鸭子图
116
+ 需要系统安装 ffmpeg
117
+ 选项:
118
+ --frames=<glob> 帧图片 glob(如 "frames/*.png")
119
+ --fps=<帧率> 默认 24
120
+ --audio=<路径> 音频文件路径(可选)
121
+ --password=<密码>
122
+ --title=<标题>
123
+ --compress=<2|6|8>
124
+
125
+ ${C.cyan}decode${C.reset} <输入.png>
126
+ 从鸭子图中还原隐藏数据
127
+ 选项:
128
+ --password=<密码> 加密密码
129
+ --output=<目录> 输出目录(默认:当前目录)
130
+ --json 只输出 JSON 元数据,不保存文件
131
+
132
+ ${C.cyan}check-ffmpeg${C.reset}
133
+ 检查系统 ffmpeg 是否可用
134
+
135
+ ${C.cyan}help${C.reset}
136
+ 显示本帮助
137
+
138
+ ${C.bold}示例${C.reset}
139
+
140
+ # 隐藏文本
141
+ node duck-cli.js encode text output.png --text="Hello 世界" --password=123
142
+
143
+ # 隐藏图片
144
+ node duck-cli.js encode image output.png --input=photo.jpg --compress=6
145
+
146
+ # 隐藏任意文件
147
+ node duck-cli.js encode bytes output.png --input=document.pdf
148
+
149
+ # 解码
150
+ node duck-cli.js decode output.png --password=123 --output=./result
151
+
152
+ # 合成视频并隐藏
153
+ node duck-cli.js encode frames output.png --frames="./frames/*.png" --fps=30
154
+
155
+ ${C.gray}注意:encode frames / encode video 需要系统安装 ffmpeg 并在 PATH 中可用${C.reset}
156
+ `;
157
+
158
+ // ─── 主逻辑 ───────────────────────────────────────────────────────────────────
159
+
160
+ async function main() {
161
+ const argv = process.argv.slice(2);
162
+ const { flags, positional } = parseArgs(argv);
163
+
164
+ const [cmd, sub] = positional;
165
+ const outputPath = positional[2];
166
+
167
+ if (!cmd || cmd === 'help' || flags.help) {
168
+ console.log(HELP_TEXT);
169
+ return;
170
+ }
171
+
172
+ // ── check-ffmpeg ──────────────────────────────────────────────────────────
173
+ if (cmd === 'check-ffmpeg') {
174
+ const { checkFfmpegAvailable } = require('./duck_video');
175
+ const available = await checkFfmpegAvailable();
176
+ if (available) ok('ffmpeg 可用');
177
+ else err('ffmpeg 不可用,请安装后将其加入 PATH');
178
+ return;
179
+ }
180
+
181
+ // ── decode ────────────────────────────────────────────────────────────────
182
+ if (cmd === 'decode') {
183
+ const inputPath = positional[1];
184
+ if (!inputPath) { err('请提供输入文件路径'); process.exit(1); }
185
+ if (!fs.existsSync(inputPath)) { err(`文件不存在:${inputPath}`); process.exit(1); }
186
+
187
+ const { decodeFromFile } = require('./duck_decode');
188
+
189
+ const password = flags.password || '';
190
+ const outputDir = flags.output || process.cwd();
191
+
192
+ info(`正在解码:${inputPath} …`);
193
+ try {
194
+ const result = await decodeFromFile(inputPath, password, outputDir);
195
+
196
+ if (flags.json) {
197
+ const meta = {
198
+ ext: result.ext,
199
+ filePath: result.filePath || null,
200
+ text: result.text || null,
201
+ dataBytes: result.data ? result.data.length : 0,
202
+ };
203
+ console.log(JSON.stringify(meta, null, 2));
204
+ } else {
205
+ ok(`解码成功(ext=${result.ext})`);
206
+ if (result.text) info(`文本内容:${result.text}`);
207
+ if (result.filePath) info(`文件已保存:${result.filePath}`);
208
+ }
209
+ } catch (e) {
210
+ err(`解码失败:${e.message}`);
211
+ process.exit(1);
212
+ }
213
+ return;
214
+ }
215
+
216
+ // ── encode ────────────────────────────────────────────────────────────────
217
+ if (cmd === 'encode') {
218
+ if (!sub) { err('请指定编码子命令:text / image / bytes / video / frames'); process.exit(1); }
219
+ if (!outputPath) { err('请提供输出文件路径'); process.exit(1); }
220
+
221
+ const password = flags.password || '';
222
+ const title = flags.title || '';
223
+ const compress = parseInt(flags.compress || '2', 10);
224
+
225
+ // ── encode text ──────────────────────────────────────────────────────────
226
+ if (sub === 'text') {
227
+ let text = flags.text;
228
+ if (!text && flags['text-file']) {
229
+ if (!fs.existsSync(flags['text-file'])) { err(`文件不存在:${flags['text-file']}`); process.exit(1); }
230
+ text = fs.readFileSync(flags['text-file'], 'utf8');
231
+ }
232
+ if (!text) { err('请通过 --text 或 --text-file 提供文本'); process.exit(1); }
233
+
234
+ const { encodeText } = require('./duck_encode');
235
+ info('正在编码文本 …');
236
+ const { filePath } = await encodeText({ text, password, title, compress, outputPath });
237
+ ok(`已写出:${filePath}`);
238
+ return;
239
+ }
240
+
241
+ // ── encode image ─────────────────────────────────────────────────────────
242
+ if (sub === 'image') {
243
+ if (!flags.input) { err('请提供 --input'); process.exit(1); }
244
+ if (!fs.existsSync(flags.input)) { err(`文件不存在:${flags.input}`); process.exit(1); }
245
+
246
+ const { encodeImage } = require('./duck_encode');
247
+ const image = fs.readFileSync(flags.input);
248
+ info(`正在编码图片:${flags.input} …`);
249
+ const { filePath } = await encodeImage({ image, password, title, compress, outputPath });
250
+ ok(`已写出:${filePath}`);
251
+ return;
252
+ }
253
+
254
+ // ── encode bytes ─────────────────────────────────────────────────────────
255
+ if (sub === 'bytes') {
256
+ if (!flags.input) { err('请提供 --input'); process.exit(1); }
257
+ if (!fs.existsSync(flags.input)) { err(`文件不存在:${flags.input}`); process.exit(1); }
258
+
259
+ const { encodeBytes } = require('./duck_encode');
260
+ const rawBytes = fs.readFileSync(flags.input);
261
+ const ext = flags.ext || path.extname(flags.input).replace('.', '') || 'bin';
262
+ info(`正在编码文件:${flags.input}(ext=${ext})…`);
263
+ const { filePath } = await encodeBytes({ rawBytes, ext, password, title, compress, outputPath });
264
+ ok(`已写出:${filePath}`);
265
+ return;
266
+ }
267
+
268
+ // ── encode video ─────────────────────────────────────────────────────────
269
+ if (sub === 'video') {
270
+ if (!flags.input) { err('请提供 --input(MP4 文件路径)'); process.exit(1); }
271
+ if (!fs.existsSync(flags.input)) { err(`文件不存在:${flags.input}`); process.exit(1); }
272
+
273
+ const { encodeMp4 } = require('./duck_encode');
274
+ const mp4Input = fs.readFileSync(flags.input);
275
+ info(`正在编码视频:${flags.input} …`);
276
+ const { filePath } = await encodeMp4({ mp4Input, password, title, compress, outputPath });
277
+ ok(`已写出:${filePath}`);
278
+ return;
279
+ }
280
+
281
+ // ── encode frames ────────────────────────────────────────────────────────
282
+ if (sub === 'frames') {
283
+ const { glob } = await import('glob').catch(() => {
284
+ // Node 18 之前无内置 glob,尝试 fast-glob
285
+ return require('fast-glob').then ? require('fast-glob') : { glob: null };
286
+ });
287
+
288
+ // 展开 glob 路径
289
+ const frameGlob = flags.frames;
290
+ if (!frameGlob) { err('请提供 --frames="<glob>"'); process.exit(1); }
291
+
292
+ let framePaths;
293
+ try {
294
+ // 若系统 Node 18+,用内置 fs.glob 或 glob 包
295
+ const { globSync } = require('glob');
296
+ framePaths = globSync(frameGlob, { absolute: true }).sort();
297
+ } catch {
298
+ // fallback:直接当文件路径
299
+ framePaths = frameGlob.split(',').map(p => p.trim()).filter(Boolean);
300
+ }
301
+
302
+ if (framePaths.length === 0) { err(`没有匹配的帧文件:${frameGlob}`); process.exit(1); }
303
+ info(`找到 ${framePaths.length} 帧`);
304
+
305
+ const frames = framePaths.map(p => fs.readFileSync(p));
306
+ const fps = parseFloat(flags.fps || '24');
307
+ const audio = flags.audio || null;
308
+
309
+ const { checkFfmpegAvailable, encodeVideoFrames } = require('./duck_video');
310
+ if (!(await checkFfmpegAvailable())) {
311
+ err('ffmpeg 不可用,请安装后重试');
312
+ process.exit(1);
313
+ }
314
+
315
+ info(`正在合成视频(${fps}fps)并编码 …`);
316
+ const { filePath } = await encodeVideoFrames({ frames, fps, audioPath: audio, password, title, compress, outputPath });
317
+ ok(`已写出:${filePath}`);
318
+ return;
319
+ }
320
+
321
+ err(`未知子命令:${sub},请使用 text / image / bytes / video / frames`);
322
+ process.exit(1);
323
+ }
324
+
325
+ err(`未知命令:${cmd}`);
326
+ console.log(`运行 ${C.cyan}node duck-cli.js help${C.reset} 查看帮助`);
327
+ process.exit(1);
328
+ }
329
+
330
+ main().catch(e => {
331
+ err(e.message || String(e));
332
+ process.exit(1);
333
+ });