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.
@@ -0,0 +1,591 @@
1
+ /**
2
+ * duck_payload_exporter.js
3
+ *
4
+ * Node.js port of duck_payload_exporter.py
5
+ * 与 Python 版本完全对齐的核心工具函数:
6
+ * - 文件头构建 / 解析
7
+ * - XOR 流密码加密 / 解密
8
+ * - LSB 像素隐写嵌入 / 提取
9
+ * - 鸭子图生成(SVG → PNG)
10
+ * - 二进制数据 ↔ PNG 像素矩阵转换
11
+ *
12
+ * 依赖:sharp(npm install sharp)
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const crypto = require('crypto');
18
+ const sharp = require('sharp');
19
+
20
+ // ─── 常量(与 Python 完全一致) ─────────────────────────────────────────────
21
+ const WATERMARK_SKIP_W_RATIO = 0.40;
22
+ const WATERMARK_SKIP_H_RATIO = 0.08;
23
+ const DUCK_CHANNELS = 3;
24
+
25
+ // ─── 位操作工具 ────────────────────────────────────────────────────────────────
26
+
27
+ /**
28
+ * 把 Buffer 转为 bit 数组(每字节 8 位,高位在前 = big-endian)
29
+ * 与 Python np.unpackbits(bitorder="big") 完全对应
30
+ */
31
+ function bytesToBits(buf) {
32
+ const bits = new Uint8Array(buf.length * 8);
33
+ for (let i = 0; i < buf.length; i++) {
34
+ const byte = buf[i];
35
+ for (let j = 0; j < 8; j++) {
36
+ bits[i * 8 + j] = (byte >> (7 - j)) & 1;
37
+ }
38
+ }
39
+ return bits;
40
+ }
41
+
42
+ /**
43
+ * 把 bit 数组(高位在前)转回 Buffer
44
+ * bit 数组长度必须是 8 的倍数(不足则右侧补 0 处理)
45
+ */
46
+ function bitsToBytes(bits) {
47
+ const byteCount = Math.ceil(bits.length / 8);
48
+ const result = Buffer.alloc(byteCount);
49
+ for (let i = 0; i < byteCount; i++) {
50
+ let b = 0;
51
+ for (let j = 0; j < 8; j++) {
52
+ const bitIdx = i * 8 + j;
53
+ if (bitIdx < bits.length) {
54
+ b = (b << 1) | (bits[bitIdx] & 1);
55
+ } else {
56
+ b = b << 1; // 补 0
57
+ }
58
+ }
59
+ result[i] = b & 0xff;
60
+ }
61
+ return result;
62
+ }
63
+
64
+ // ─── 密钥流生成(SHA-256 迭代,与 Python 对齐) ─────────────────────────────
65
+
66
+ /**
67
+ * 生成任意长度的密钥流
68
+ * key_material = password + salt.hex(),迭代 SHA-256 直到长度足够
69
+ *
70
+ * @param {string} password
71
+ * @param {Buffer} salt 16 字节随机盐
72
+ * @param {number} length 所需字节数
73
+ * @returns {Buffer}
74
+ */
75
+ function generateKeyStream(password, salt, length) {
76
+ const keyMaterial = Buffer.from(password + salt.toString('hex'), 'utf-8');
77
+ const out = [];
78
+ let counter = 0;
79
+ while (out.length < length) {
80
+ const hash = crypto
81
+ .createHash('sha256')
82
+ .update(Buffer.concat([keyMaterial, Buffer.from(String(counter), 'utf-8')]))
83
+ .digest();
84
+ for (const b of hash) out.push(b);
85
+ counter++;
86
+ }
87
+ return Buffer.from(out.slice(0, length));
88
+ }
89
+
90
+ // ─── 加密 / 解密 ──────────────────────────────────────────────────────────────
91
+
92
+ /**
93
+ * XOR 流密码加密
94
+ * @param {Buffer} data
95
+ * @param {string} password 空字符串 = 不加密
96
+ * @returns {{ cipher: Buffer, salt: Buffer, pwdHash: Buffer, hasPwd: boolean }}
97
+ */
98
+ function encryptWithPassword(data, password) {
99
+ if (!password) {
100
+ return { cipher: data, salt: Buffer.alloc(0), pwdHash: Buffer.alloc(0), hasPwd: false };
101
+ }
102
+ const salt = crypto.randomBytes(16);
103
+ const keyStream = generateKeyStream(password, salt, data.length);
104
+ const cipher = Buffer.from(data.map((b, i) => b ^ keyStream[i]));
105
+ const pwdHash = crypto
106
+ .createHash('sha256')
107
+ .update(Buffer.from(password + salt.toString('hex'), 'utf-8'))
108
+ .digest();
109
+ return { cipher, salt, pwdHash, hasPwd: true };
110
+ }
111
+
112
+ // ─── 文件头构建 / 解析 ───────────────────────────────────────────────────────
113
+
114
+ /**
115
+ * 构建文件头(与 Python _build_file_header 完全对齐的二进制格式)
116
+ *
117
+ * 格式(无密码):
118
+ * [0x00][extLen:1][ext:extLen][dataLen:4 big-endian][data]
119
+ *
120
+ * 格式(有密码):
121
+ * [0x01][pwdHash:32][salt:16][extLen:1][ext:extLen][dataLen:4 big-endian][encryptedData]
122
+ *
123
+ * @param {Buffer} raw 原始数据字节
124
+ * @param {string} password 加密密码,空表示不加密
125
+ * @param {string} ext 扩展名,如 "png"、"txt"、"mp4.binpng"
126
+ * @returns {Buffer}
127
+ */
128
+ function buildFileHeader(raw, password, ext = 'png') {
129
+ const { cipher, salt, pwdHash, hasPwd } = encryptWithPassword(raw, password);
130
+ const payload = cipher;
131
+ const extBytes = Buffer.from(ext, 'utf-8');
132
+ const dataLenBuf = Buffer.alloc(4);
133
+ dataLenBuf.writeUInt32BE(payload.length);
134
+
135
+ const parts = [Buffer.from([hasPwd ? 1 : 0])];
136
+ if (hasPwd) {
137
+ parts.push(pwdHash); // 32 bytes
138
+ parts.push(salt); // 16 bytes
139
+ }
140
+ parts.push(Buffer.from([extBytes.length])); // 1 byte ext length
141
+ parts.push(extBytes);
142
+ parts.push(dataLenBuf);
143
+ parts.push(payload);
144
+
145
+ return Buffer.concat(parts);
146
+ }
147
+
148
+ /**
149
+ * 解析文件头,验证密码,返回原始数据和扩展名
150
+ *
151
+ * @param {Buffer} header
152
+ * @param {string} password 解密密码,无加密时传空字符串即可
153
+ * @returns {{ data: Buffer, ext: string }}
154
+ * @throws {Error} 密码错误 / 文件头损坏 / 数据长度不匹配
155
+ */
156
+ function parseHeader(header, password) {
157
+ let idx = 0;
158
+ if (header.length < 1) throw new Error('Header corrupted. 文件头损坏');
159
+
160
+ const hasPwd = header[idx] === 1;
161
+ idx += 1;
162
+
163
+ let pwdHash = Buffer.alloc(0);
164
+ let salt = Buffer.alloc(0);
165
+ if (hasPwd) {
166
+ if (header.length < idx + 32 + 16) throw new Error('Header corrupted. 文件头损坏');
167
+ pwdHash = header.slice(idx, idx + 32); idx += 32;
168
+ salt = header.slice(idx, idx + 16); idx += 16;
169
+ }
170
+
171
+ if (header.length < idx + 1) throw new Error('Header corrupted. 文件头损坏');
172
+ const extLen = header[idx]; idx += 1;
173
+
174
+ if (header.length < idx + extLen + 4) throw new Error('Header corrupted. 文件头损坏');
175
+ const ext = header.slice(idx, idx + extLen).toString('utf-8'); idx += extLen;
176
+ const dataLen = header.readUInt32BE(idx); idx += 4;
177
+ const data = header.slice(idx);
178
+
179
+ if (data.length !== dataLen) throw new Error('Data length mismatch. 数据长度不匹配');
180
+
181
+ if (!hasPwd) return { data, ext };
182
+
183
+ // 密码验证
184
+ if (!password) throw new Error('Password required. 需要密码');
185
+ const checkHash = crypto
186
+ .createHash('sha256')
187
+ .update(Buffer.from(password + salt.toString('hex'), 'utf-8'))
188
+ .digest();
189
+ if (!checkHash.equals(pwdHash)) throw new Error('Wrong password. 密码错误');
190
+
191
+ // 解密
192
+ const keyStream = generateKeyStream(password, salt, data.length);
193
+ const plain = Buffer.from(data.map((b, i) => b ^ keyStream[i]));
194
+ return { data: plain, ext };
195
+ }
196
+
197
+ // ─── 画布尺寸计算 ─────────────────────────────────────────────────────────────
198
+
199
+ /**
200
+ * 计算容纳 bitLen 个 bit 所需的最小正方形画布边长
201
+ * 与 Python _required_canvas_size 完全对齐
202
+ *
203
+ * @param {number} bitLen 需要存储的总 bit 数
204
+ * @param {number} lsbBits 每通道使用的 LSB 位数
205
+ * @returns {number} 画布边长(像素)
206
+ */
207
+ function requiredCanvasSize(bitLen, lsbBits) {
208
+ let side = 640;
209
+ while (true) {
210
+ const skipW = Math.floor(side * WATERMARK_SKIP_W_RATIO);
211
+ const skipH = Math.floor(side * WATERMARK_SKIP_H_RATIO);
212
+ const excluded = skipW * skipH;
213
+ const usableBits = (side * side - excluded) * DUCK_CHANNELS * lsbBits;
214
+ if (usableBits >= bitLen) return side;
215
+ side += 64;
216
+ }
217
+ }
218
+
219
+ // ─── 鸭子图生成(SVG) ────────────────────────────────────────────────────────
220
+
221
+ /**
222
+ * 用弧度和椭圆参数计算 SVG arc 路径的起点 / 终点坐标
223
+ */
224
+ function _ellipsePoint(cx, cy, rx, ry, angleDeg) {
225
+ const rad = (angleDeg * Math.PI) / 180;
226
+ return { x: cx + rx * Math.cos(rad), y: cy + ry * Math.sin(rad) };
227
+ }
228
+
229
+ /**
230
+ * 生成鸭子图 SVG 字符串(与 Python _build_duck_image 视觉接近)
231
+ *
232
+ * @param {number} size 画布边长
233
+ * @param {string} title 标题文字(最多 30 字符)
234
+ * @returns {string} SVG 字符串
235
+ */
236
+ function buildDuckImageSVG(size, title = '') {
237
+ const s = size;
238
+
239
+ // ── 水波弧线辅助(对应 PIL draw.arc) ─────────────────────────────────
240
+ function arcPath(bbX0, bbY0, bbX1, bbY1, startDeg, endDeg) {
241
+ const cx = (bbX0 + bbX1) / 2;
242
+ const cy = (bbY0 + bbY1) / 2;
243
+ const rx = (bbX1 - bbX0) / 2;
244
+ const ry = (bbY1 - bbY0) / 2;
245
+ const p1 = _ellipsePoint(cx, cy, rx, ry, startDeg);
246
+ const p2 = _ellipsePoint(cx, cy, rx, ry, endDeg);
247
+ // 从 startDeg 顺时针到 endDeg,角度跨度 = endDeg - startDeg
248
+ const sweep = 1; // 顺时针
249
+ const arcDeg = endDeg - startDeg;
250
+ const large = arcDeg > 180 ? 1 : 0;
251
+ return `M ${p1.x.toFixed(2)},${p1.y.toFixed(2)} A ${rx.toFixed(2)},${ry.toFixed(2)} 0 ${large},${sweep} ${p2.x.toFixed(2)},${p2.y.toFixed(2)}`;
252
+ }
253
+
254
+ // ── 鸭嘴多边形顶点 ─────────────────────────────────────────────────────
255
+ const beakPts = [
256
+ [s * 0.65, s * 0.32],
257
+ [s * 0.78, s * 0.36],
258
+ [s * 0.68, s * 0.40],
259
+ [s * 0.60, s * 0.38],
260
+ ].map(([x, y]) => `${x.toFixed(2)},${y.toFixed(2)}`).join(' ');
261
+
262
+ // ── 标题文字 ───────────────────────────────────────────────────────────
263
+ const titleStr = title ? title.slice(0, 30) : '';
264
+ const fontSize = Math.max(12, Math.floor(s * 0.06));
265
+ const titleElem = titleStr
266
+ ? `<text x="${Math.floor(s * 0.06)}" y="${Math.floor(s * 0.16)}"
267
+ font-size="${fontSize}" font-family="sans-serif" fill="rgba(0,0,0,0.85)"
268
+ text-anchor="start">${escapeXml(titleStr)}</text>`
269
+ : '';
270
+
271
+ // ── 版本号 ─────────────────────────────────────────────────────────────
272
+ const verFontSize = Math.max(8, Math.floor(s * 0.022));
273
+ const verElem = `<text x="${(s / 2).toFixed(0)}" y="${(s * 0.94).toFixed(0)}"
274
+ font-size="${verFontSize}" font-family="sans-serif" fill="rgba(255,255,255,0.9)"
275
+ text-anchor="middle">V1.0</text>`;
276
+
277
+ return `<?xml version="1.0" encoding="UTF-8"?>
278
+ <svg width="${s}" height="${s}" xmlns="http://www.w3.org/2000/svg">
279
+ <!-- 天空背景 -->
280
+ <rect width="${s}" height="${s}" fill="rgb(153,204,255)"/>
281
+
282
+ <!-- 身体 -->
283
+ <ellipse cx="${s * 0.5}" cy="${s * 0.6}" rx="${s * 0.3}" ry="${s * 0.25}"
284
+ fill="rgb(255,223,94)" stroke="rgb(255,190,60)" stroke-width="4"/>
285
+
286
+ <!-- 头部 -->
287
+ <ellipse cx="${s * 0.5}" cy="${s * 0.3}" rx="${s * 0.15}" ry="${s * 0.15}"
288
+ fill="rgb(255,223,94)" stroke="rgb(255,190,60)" stroke-width="4"/>
289
+
290
+ <!-- 翅膀 -->
291
+ <ellipse cx="${s * 0.575}" cy="${s * 0.65}" rx="${s * 0.175}" ry="${s * 0.1}"
292
+ fill="rgb(255,200,70)" stroke="rgb(255,190,60)" stroke-width="3"/>
293
+
294
+ <!-- 嘴巴 -->
295
+ <polygon points="${beakPts}" fill="rgb(255,153,51)" stroke="rgb(200,120,30)" stroke-width="2"/>
296
+
297
+ <!-- 眼睛(右) -->
298
+ <ellipse cx="${s * 0.58}" cy="${s * 0.26}" rx="${s * 0.02}" ry="${s * 0.02}" fill="black"/>
299
+
300
+ <!-- 眼睛(左) -->
301
+ <ellipse cx="${s * 0.49}" cy="${s * 0.26}" rx="${s * 0.02}" ry="${s * 0.02}" fill="black"/>
302
+
303
+ <!-- 水波纹 1 -->
304
+ <path d="${arcPath(s * 0.1, s * 0.75, s * 0.9, s * 0.9, 10, 170)}"
305
+ stroke="rgba(255,255,255,0.9)" fill="none" stroke-width="3"/>
306
+
307
+ <!-- 水波纹 2 -->
308
+ <path d="${arcPath(s * 0.15, s * 0.78, s * 0.85, s * 0.93, 10, 170)}"
309
+ stroke="rgba(240,240,240,0.7)" fill="none" stroke-width="2"/>
310
+
311
+ ${titleElem}
312
+ ${verElem}
313
+ </svg>`;
314
+ }
315
+
316
+ function escapeXml(str) {
317
+ return str.replace(/&/g, '&amp;')
318
+ .replace(/</g, '&lt;')
319
+ .replace(/>/g, '&gt;')
320
+ .replace(/"/g, '&quot;')
321
+ .replace(/'/g, '&apos;');
322
+ }
323
+
324
+ /**
325
+ * 生成鸭子图 PNG(Buffer)
326
+ *
327
+ * @param {number} size
328
+ * @param {string} [title]
329
+ * @returns {Promise<Buffer>} PNG 格式
330
+ */
331
+ async function buildDuckImageBuffer(size, title = '') {
332
+ const svgStr = buildDuckImageSVG(size, title);
333
+ return sharp(Buffer.from(svgStr))
334
+ .resize(size, size)
335
+ .removeAlpha()
336
+ .png({ compressionLevel: 9 })
337
+ .toBuffer();
338
+ }
339
+
340
+ // ─── LSB 嵌入 ─────────────────────────────────────────────────────────────────
341
+
342
+ /**
343
+ * LSB 隐写嵌入(与 Python _embed_payload_lsb 完全对齐)
344
+ *
345
+ * 算法要点:
346
+ * 1. 跳过左上角 40%宽 × 8%高 的水印区域
347
+ * 2. 把 [4字节大端长度 + fileHeader字节] 展开为 bit 数组
348
+ * 3. 每 lsbBits 个 bit 为一组写入一个通道的最低位
349
+ * 4. 处理完后把水印区域的像素用右侧相邻像素填充(视觉自然)
350
+ *
351
+ * @param {Buffer} pngBuffer 输入图片(PNG)
352
+ * @param {Buffer} fileHeader 由 buildFileHeader() 生成的文件头
353
+ * @param {number} lsbBits 每通道 LSB 位数(2 / 6 / 8)
354
+ * @returns {Promise<Buffer>} 嵌入数据后的 PNG
355
+ */
356
+ async function embedPayloadLSB(pngBuffer, fileHeader, lsbBits) {
357
+ // 获取原始 RGB 像素
358
+ const { data: rawPixels, info } = await sharp(pngBuffer)
359
+ .toColorspace('srgb')
360
+ .removeAlpha()
361
+ .raw()
362
+ .toBuffer({ resolveWithObject: true });
363
+
364
+ const { width, height } = info;
365
+
366
+ // 构造 [长度前缀(4字节)] + [fileHeader]
367
+ const lenBuf = Buffer.alloc(4);
368
+ lenBuf.writeUInt32BE(fileHeader.length);
369
+ const payload = Buffer.concat([lenBuf, fileHeader]);
370
+
371
+ // 展开为 bit 数组并补齐到 lsbBits 的整数倍
372
+ const bits = Array.from(bytesToBits(payload));
373
+ const groups = Math.ceil(bits.length / lsbBits);
374
+ while (bits.length < groups * lsbBits) bits.push(0);
375
+
376
+ const skipW = Math.floor(width * WATERMARK_SKIP_W_RATIO);
377
+ const skipH = Math.floor(height * WATERMARK_SKIP_H_RATIO);
378
+ const lsbMask = (1 << lsbBits) - 1;
379
+
380
+ const pixels = Buffer.from(rawPixels); // 复制,不修改原始 buffer
381
+ let groupIdx = 0;
382
+
383
+ // ── 按行 → 列 → 通道顺序嵌入(与 Python flatten 顺序一致) ───────────────
384
+ outer:
385
+ for (let y = 0; y < height; y++) {
386
+ for (let x = 0; x < width; x++) {
387
+ // 跳过水印区域
388
+ if (y < skipH && x < skipW) continue;
389
+ if (groupIdx >= groups) break outer;
390
+
391
+ for (let c = 0; c < 3; c++) {
392
+ if (groupIdx >= groups) break;
393
+
394
+ // 把 lsbBits 个 bit 组合成一个值(高位在前)
395
+ let val = 0;
396
+ for (let b = 0; b < lsbBits; b++) {
397
+ val = (val << 1) | (bits[groupIdx * lsbBits + b] & 1);
398
+ }
399
+
400
+ const idx = (y * width + x) * 3 + c;
401
+ pixels[idx] = (pixels[idx] & ~lsbMask) | (val & lsbMask);
402
+ groupIdx++;
403
+ }
404
+ }
405
+ }
406
+
407
+ if (groupIdx < groups) {
408
+ throw new Error('Data too large, capacity exceeded. 数据过大,鸭子图容量不够。');
409
+ }
410
+
411
+ // ── 用右侧像素填充水印区域(与 Python 逻辑对齐) ─────────────────────────
412
+ const srcW = Math.max(0, width - skipW);
413
+ if (skipW > 0 && skipH > 0 && srcW > 0) {
414
+ const blockW = Math.min(skipW, srcW);
415
+ for (let y = 0; y < skipH; y++) {
416
+ for (let x = 0; x < skipW; x++) {
417
+ const srcX = skipW + (x % blockW);
418
+ const dstI = (y * width + x) * 3;
419
+ const srcI = (y * width + srcX) * 3;
420
+ pixels[dstI] = pixels[srcI];
421
+ pixels[dstI + 1] = pixels[srcI + 1];
422
+ pixels[dstI + 2] = pixels[srcI + 2];
423
+ }
424
+ }
425
+ }
426
+
427
+ // 转回 PNG
428
+ return sharp(pixels, { raw: { width, height, channels: 3 } })
429
+ .png({ compressionLevel: 9 })
430
+ .toBuffer();
431
+ }
432
+
433
+ // ─── LSB 提取 ─────────────────────────────────────────────────────────────────
434
+
435
+ /**
436
+ * 从原始 RGB 像素中提取 LSB payload(与 Python _extract_payload_with_k 对齐)
437
+ *
438
+ * @param {Buffer} rawPixels uint8 RGB 行优先像素,大小 = height * width * 3
439
+ * @param {number} width
440
+ * @param {number} height
441
+ * @param {number} k LSB 位数(2 / 6 / 8)
442
+ * @returns {Buffer} 提取出的 payload(不含长度前缀)
443
+ * @throws {Error} 数据不足 / 长度异常
444
+ */
445
+ function extractPayloadWithK(rawPixels, width, height, k) {
446
+ const skipW = Math.floor(width * WATERMARK_SKIP_W_RATIO);
447
+ const skipH = Math.floor(height * WATERMARK_SKIP_H_RATIO);
448
+ const mask = (1 << k) - 1;
449
+
450
+ // 先收集所有可用通道的 bit
451
+ const allBits = [];
452
+
453
+ for (let y = 0; y < height; y++) {
454
+ for (let x = 0; x < width; x++) {
455
+ if (y < skipH && x < skipW) continue;
456
+ for (let c = 0; c < 3; c++) {
457
+ const val = rawPixels[(y * width + x) * 3 + c] & mask;
458
+ // 展开为 k 位(高位在前,与 Python np.unpackbits[-k:] 一致)
459
+ for (let b = k - 1; b >= 0; b--) {
460
+ allBits.push((val >> b) & 1);
461
+ }
462
+ }
463
+ }
464
+ }
465
+
466
+ if (allBits.length < 32) throw new Error('Insufficient image data. 图像数据不足');
467
+
468
+ // 读前 32 bit → 大端 uint32 = header_len
469
+ let headerLen = 0;
470
+ for (let i = 0; i < 32; i++) {
471
+ headerLen = ((headerLen << 1) | allBits[i]) >>> 0;
472
+ }
473
+
474
+ const totalBits = 32 + headerLen * 8;
475
+ if (headerLen <= 0 || totalBits > allBits.length) {
476
+ throw new Error('Payload length invalid. 载荷长度异常');
477
+ }
478
+
479
+ // 提取 payload bits
480
+ const payloadBits = allBits.slice(32, 32 + headerLen * 8);
481
+ return bitsToBytes(payloadBits);
482
+ }
483
+
484
+ // ─── 二进制数据 ↔ PNG 像素矩阵(用于视频字节的中间存储) ────────────────────
485
+
486
+ /**
487
+ * 将任意二进制数据铺入 PNG 像素(每 3 字节 = 1 像素)
488
+ * 与 Python _bytes_to_binary_image 完全对齐
489
+ *
490
+ * @param {Buffer} data
491
+ * @param {number} [width=512]
492
+ * @returns {Promise<Buffer>} PNG buffer
493
+ */
494
+ async function bytesToBinaryImage(data, width = 512) {
495
+ const pixelCount = Math.ceil(data.length / 3);
496
+ const height = Math.ceil(pixelCount / width);
497
+ const totalBytes = width * height * 3;
498
+ const padded = Buffer.alloc(totalBytes); // 不足补 0
499
+ data.copy(padded);
500
+ return sharp(padded, { raw: { width, height, channels: 3 } })
501
+ .png({ compressionLevel: 0 }) // 无损,不压缩原始字节
502
+ .toBuffer();
503
+ }
504
+
505
+ /**
506
+ * 从 binpng 还原原始二进制数据(剥离尾部零填充)
507
+ * 与 Python binpng_bytes_to_mp4_bytes 对应
508
+ *
509
+ * @param {Buffer} pngBuffer binpng 格式的 PNG buffer
510
+ * @returns {Promise<Buffer>}
511
+ */
512
+ async function binaryImageToBytes(pngBuffer) {
513
+ const { data: rawPixels } = await sharp(pngBuffer)
514
+ .toColorspace('srgb')
515
+ .removeAlpha()
516
+ .raw()
517
+ .toBuffer({ resolveWithObject: true });
518
+
519
+ // 去掉尾部零填充
520
+ let end = rawPixels.length;
521
+ while (end > 0 && rawPixels[end - 1] === 0) end--;
522
+ return Buffer.from(rawPixels.slice(0, end));
523
+ }
524
+
525
+ // ─── 主导出函数 ───────────────────────────────────────────────────────────────
526
+
527
+ /**
528
+ * 完整的鸭子图编码流程(对应 Python export_duck_payload)
529
+ *
530
+ * @param {object} opts
531
+ * @param {Buffer} opts.rawBytes 原始载荷字节
532
+ * @param {string} opts.password 加密密码(空字符串 = 不加密)
533
+ * @param {string} opts.ext 扩展名,如 "png" / "txt" / "mp4.binpng"
534
+ * @param {number} opts.compress 压缩模式:2 / 6 / 8(对应 LSB 位数)
535
+ * @param {string} [opts.title] 鸭子图标题
536
+ * @param {number} [opts.fixedSize] 固定画布尺寸(可选,用于批量保持一致)
537
+ * @returns {Promise<{ imageBuffer: Buffer }>}
538
+ */
539
+ async function exportDuckPayload({ rawBytes, password, ext, compress, title = '', fixedSize = null }) {
540
+ const fileHeader = buildFileHeader(rawBytes, password, ext);
541
+ const lsbBits = compress >= 8 ? 8 : compress >= 6 ? 6 : 2;
542
+
543
+ let requiredSize = requiredCanvasSize((fileHeader.length + 4) * 8, lsbBits);
544
+ if (fixedSize !== null) {
545
+ requiredSize = fixedSize >= requiredSize ? fixedSize : requiredSize;
546
+ }
547
+
548
+ const duckPng = await buildDuckImageBuffer(requiredSize, title);
549
+ const resultPng = await embedPayloadLSB(duckPng, fileHeader, lsbBits);
550
+
551
+ return { imageBuffer: resultPng };
552
+ }
553
+
554
+ // ─── 导出 ─────────────────────────────────────────────────────────────────────
555
+
556
+ module.exports = {
557
+ // 常量
558
+ WATERMARK_SKIP_W_RATIO,
559
+ WATERMARK_SKIP_H_RATIO,
560
+ DUCK_CHANNELS,
561
+
562
+ // 位工具
563
+ bytesToBits,
564
+ bitsToBytes,
565
+
566
+ // 加密
567
+ generateKeyStream,
568
+ encryptWithPassword,
569
+
570
+ // 文件头
571
+ buildFileHeader,
572
+ parseHeader,
573
+
574
+ // 画布尺寸
575
+ requiredCanvasSize,
576
+
577
+ // 图片生成
578
+ buildDuckImageSVG,
579
+ buildDuckImageBuffer,
580
+
581
+ // LSB
582
+ embedPayloadLSB,
583
+ extractPayloadWithK,
584
+
585
+ // 二进制 ↔ PNG
586
+ bytesToBinaryImage,
587
+ binaryImageToBytes,
588
+
589
+ // 顶层封装
590
+ exportDuckPayload,
591
+ };