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/duck_decode.js ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * duck_decode.js
3
+ *
4
+ * Node.js 解码器:从鸭子图还原隐藏的数据
5
+ * 对应 Python duck_decode_node.py 的核心逻辑(去除 ComfyUI 节点壳)
6
+ *
7
+ * 还原类型:
8
+ * - 图片(ext = "png")→ PNG Buffer
9
+ * - 文本(ext = "txt")→ string
10
+ * - MP4(ext = "mp4" 或 "mp4.binpng")→ MP4 Buffer
11
+ * - 其他任意二进制(ext = 任意)→ Buffer
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const sharp = require('sharp');
19
+
20
+ const {
21
+ extractPayloadWithK,
22
+ parseHeader,
23
+ binaryImageToBytes,
24
+ } = require('./duck_payload_exporter');
25
+
26
+ // ─── 主解码函数 ───────────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * 从鸭子图中解码出隐藏的数据
30
+ *
31
+ * 自动尝试 LSB k=2、k=6、k=8 三种模式(与 Python 一致)
32
+ *
33
+ * @param {object} opts
34
+ * @param {Buffer|string} opts.duckImage 鸭子图(PNG Buffer 或文件路径)
35
+ * @param {string} [opts.password] 解密密码(无加密时留空)
36
+ * @param {string} [opts.outputDir] 若提供则把还原文件写入此目录
37
+ * @returns {Promise<DecodeResult>}
38
+ *
39
+ * @typedef {object} DecodeResult
40
+ * @property {Buffer} data 原始数据字节
41
+ * @property {string} ext 文件扩展名
42
+ * @property {string} [filePath] 写出的文件路径(outputDir 不为空时有值)
43
+ * @property {string} [text] ext='txt' 时的 UTF-8 文本内容
44
+ * @property {Buffer} [imageBuffer] ext='png' 时的 PNG Buffer(即 data 本身)
45
+ * @property {Buffer} [mp4Buffer] ext='mp4' 或 'mp4.binpng' 时的 MP4 Buffer
46
+ */
47
+ async function decodeDuckImage({ duckImage, password = '', outputDir = null }) {
48
+ // ── 加载图片 → 原始 RGB 像素 ─────────────────────────────────────────────
49
+ const inputBuffer = typeof duckImage === 'string'
50
+ ? await fs.promises.readFile(duckImage)
51
+ : duckImage;
52
+
53
+ const { data: rawPixels, info } = await sharp(inputBuffer)
54
+ .toColorspace('srgb')
55
+ .removeAlpha()
56
+ .raw()
57
+ .toBuffer({ resolveWithObject: true });
58
+
59
+ const { width, height } = info;
60
+
61
+ // ── 自动探测 LSB 位数(k = 2 → 6 → 8) ──────────────────────────────────
62
+ let payload = null;
63
+ let parsed = null;
64
+ let lastErr = null;
65
+
66
+ for (const k of [2, 6, 8]) {
67
+ try {
68
+ payload = extractPayloadWithK(rawPixels, width, height, k);
69
+ parsed = parseHeader(payload, password);
70
+ break; // 成功则退出循环
71
+ } catch (err) {
72
+ lastErr = err;
73
+ payload = null;
74
+ parsed = null;
75
+ }
76
+ }
77
+
78
+ if (!parsed) {
79
+ throw lastErr || new Error('解析失败,无法从图片中提取有效数据');
80
+ }
81
+
82
+ const { data, ext } = parsed;
83
+ const result = { data, ext };
84
+
85
+ // ── 按扩展名分发处理 ──────────────────────────────────────────────────────
86
+ if (ext.toLowerCase() === 'txt') {
87
+ // 文本:解码 UTF-8,回退 GBK(Node.js 原生不支持 GBK,回退时保留原始 Buffer)
88
+ let text = '';
89
+ try {
90
+ text = data.toString('utf-8');
91
+ } catch {
92
+ try {
93
+ // 如果系统安装了 iconv-lite,可以尝试 GBK 解码
94
+ const iconv = require('iconv-lite');
95
+ text = iconv.decode(data, 'gbk');
96
+ } catch {
97
+ text = `[无法解码文本内容,原始数据已存储于 data 字段]`;
98
+ }
99
+ }
100
+ result.text = text;
101
+
102
+ } else if (ext.toLowerCase().endsWith('.binpng') || ext.toLowerCase() === 'mp4') {
103
+ // 视频:binpng → 还原 MP4 字节,或直接使用 MP4 字节
104
+ let mp4Bytes;
105
+ if (ext.toLowerCase().endsWith('.binpng')) {
106
+ // binpng 格式:data 本身就是一张 PNG(像素 = MP4 字节)
107
+ mp4Bytes = await binaryImageToBytes(data);
108
+ } else {
109
+ mp4Bytes = data;
110
+ }
111
+ result.mp4Buffer = mp4Bytes;
112
+
113
+ } else if (ext.toLowerCase() === 'png') {
114
+ result.imageBuffer = data;
115
+
116
+ }
117
+ // 其他格式(jpg、zip 等):data 即原始字节,调用方自行处理
118
+
119
+ // ── 可选:写出文件 ────────────────────────────────────────────────────────
120
+ if (outputDir) {
121
+ await fs.promises.mkdir(outputDir, { recursive: true });
122
+ const safeName = 'duck_recovered.' + (ext.endsWith('.binpng') ? 'mp4' : ext.replace(/[/\\?%*:|"<>]/g, '_'));
123
+ const filePath = path.join(outputDir, safeName);
124
+
125
+ const bytesToWrite = result.mp4Buffer || result.imageBuffer || data;
126
+ await fs.promises.writeFile(filePath, bytesToWrite);
127
+ result.filePath = filePath;
128
+ }
129
+
130
+ return result;
131
+ }
132
+
133
+ /**
134
+ * 从本地文件解码(便捷方法)
135
+ *
136
+ * @param {string} duckFilePath 鸭子图文件路径
137
+ * @param {string} [password]
138
+ * @param {string} [outputDir]
139
+ * @returns {Promise<DecodeResult>}
140
+ */
141
+ async function decodeFromFile(duckFilePath, password = '', outputDir = null) {
142
+ return decodeDuckImage({ duckImage: duckFilePath, password, outputDir });
143
+ }
144
+
145
+ /**
146
+ * 从 Buffer 解码(便捷方法)
147
+ *
148
+ * @param {Buffer} duckBuffer 鸭子图 PNG Buffer
149
+ * @param {string} [password]
150
+ * @param {string} [outputDir]
151
+ * @returns {Promise<DecodeResult>}
152
+ */
153
+ async function decodeFromBuffer(duckBuffer, password = '', outputDir = null) {
154
+ return decodeDuckImage({ duckImage: duckBuffer, password, outputDir });
155
+ }
156
+
157
+ module.exports = {
158
+ decodeDuckImage,
159
+ decodeFromFile,
160
+ decodeFromBuffer,
161
+ };
package/duck_encode.js ADDED
@@ -0,0 +1,219 @@
1
+ /**
2
+ * duck_encode.js
3
+ *
4
+ * Node.js 编码器:把图片 / 文本数据隐藏进鸭子图
5
+ * 对应 Python duck_encode_node.py 的核心逻辑(去除 ComfyUI 节点壳)
6
+ *
7
+ * 支持的输入类型:
8
+ * - 单张图片(PNG/JPEG Buffer 或文件路径)
9
+ * - 多张图片(Buffer 数组 —— 输出图片序列,每帧独立生成一张鸭子图)
10
+ * - 纯文本字符串
11
+ *
12
+ * 注意:视频合成(对应 Python _images_to_video)依赖 ffmpeg,本文件不包含,
13
+ * 如需合成视频请在外部使用 fluent-ffmpeg 将帧序列合成 MP4 后,
14
+ * 把 MP4 Buffer 通过 encodeBytes({ rawBytes, ext: 'mp4.binpng', ... }) 编码。
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const sharp = require('sharp');
22
+
23
+ const {
24
+ buildFileHeader,
25
+ exportDuckPayload,
26
+ bytesToBinaryImage,
27
+ requiredCanvasSize,
28
+ embedPayloadLSB,
29
+ buildDuckImageBuffer,
30
+ } = require('./duck_payload_exporter');
31
+
32
+ // ─── 工具:图片 Buffer → PNG 字节 ────────────────────────────────────────────
33
+
34
+ /**
35
+ * 读取图片文件 / Buffer,统一转成 PNG Buffer
36
+ */
37
+ async function toPngBuffer(input) {
38
+ if (typeof input === 'string') {
39
+ return sharp(input).toColorspace('srgb').removeAlpha().png().toBuffer();
40
+ }
41
+ if (Buffer.isBuffer(input)) {
42
+ return sharp(input).toColorspace('srgb').removeAlpha().png().toBuffer();
43
+ }
44
+ throw new TypeError(`不支持的输入类型:${typeof input}`);
45
+ }
46
+
47
+ // ─── 主编码函数 ───────────────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * 把单张图片编码为鸭子图
51
+ *
52
+ * @param {object} opts
53
+ * @param {Buffer|string} opts.image 输入图片(Buffer 或文件路径)
54
+ * @param {string} [opts.password] 加密密码
55
+ * @param {string} [opts.title] 鸭子图标题
56
+ * @param {number} [opts.compress] 压缩模式 2/6/8(默认 2)
57
+ * @param {string} [opts.outputPath] 若提供则保存 PNG 文件
58
+ * @returns {Promise<{ imageBuffer: Buffer, filePath?: string }>}
59
+ */
60
+ async function encodeImage({ image, password = '', title = '', compress = 2, outputPath = null }) {
61
+ const pngBytes = await toPngBuffer(image);
62
+ const result = await exportDuckPayload({
63
+ rawBytes: pngBytes,
64
+ password,
65
+ ext: 'png',
66
+ compress,
67
+ title,
68
+ });
69
+
70
+ if (outputPath) {
71
+ await fs.promises.writeFile(outputPath, result.imageBuffer);
72
+ return { imageBuffer: result.imageBuffer, filePath: outputPath };
73
+ }
74
+ return { imageBuffer: result.imageBuffer };
75
+ }
76
+
77
+ /**
78
+ * 把纯文本编码为鸭子图
79
+ *
80
+ * @param {object} opts
81
+ * @param {string} opts.text 待隐藏的文本(UTF-8)
82
+ * @param {string} [opts.password] 加密密码
83
+ * @param {string} [opts.title] 鸭子图标题
84
+ * @param {number} [opts.compress] 2/6/8(默认 2)
85
+ * @param {string} [opts.outputPath] 保存路径
86
+ * @returns {Promise<{ imageBuffer: Buffer, filePath?: string }>}
87
+ */
88
+ async function encodeText({ text, password = '', title = '', compress = 2, outputPath = null }) {
89
+ const rawBytes = Buffer.from(text, 'utf-8');
90
+ const result = await exportDuckPayload({
91
+ rawBytes,
92
+ password,
93
+ ext: 'txt',
94
+ compress,
95
+ title,
96
+ });
97
+
98
+ if (outputPath) {
99
+ await fs.promises.writeFile(outputPath, result.imageBuffer);
100
+ return { imageBuffer: result.imageBuffer, filePath: outputPath };
101
+ }
102
+ return { imageBuffer: result.imageBuffer };
103
+ }
104
+
105
+ /**
106
+ * 把任意二进制数据编码为鸭子图(通用接口)
107
+ *
108
+ * @param {object} opts
109
+ * @param {Buffer} opts.rawBytes 原始字节
110
+ * @param {string} opts.ext 扩展名,如 "png" / "txt" / "mp4.binpng"
111
+ * @param {string} [opts.password]
112
+ * @param {string} [opts.title]
113
+ * @param {number} [opts.compress]
114
+ * @param {string} [opts.outputPath]
115
+ * @returns {Promise<{ imageBuffer: Buffer, filePath?: string }>}
116
+ */
117
+ async function encodeBytes({ rawBytes, ext, password = '', title = '', compress = 2, outputPath = null }) {
118
+ const result = await exportDuckPayload({ rawBytes, password, ext, compress, title });
119
+
120
+ if (outputPath) {
121
+ await fs.promises.writeFile(outputPath, result.imageBuffer);
122
+ return { imageBuffer: result.imageBuffer, filePath: outputPath };
123
+ }
124
+ return { imageBuffer: result.imageBuffer };
125
+ }
126
+
127
+ /**
128
+ * 把多张图片编码为「图片序列」(每帧一张独立的鸭子图)
129
+ * 所有帧使用统一的画布尺寸(对应 Python combine_video=False 分支)
130
+ *
131
+ * @param {object} opts
132
+ * @param {Array<Buffer|string>} opts.images 图片数组
133
+ * @param {string} [opts.password]
134
+ * @param {string} [opts.title]
135
+ * @param {number} [opts.compress]
136
+ * @param {string} [opts.outputDir] 若提供则逐帧保存 PNG
137
+ * @returns {Promise<{ frames: Buffer[], filePaths?: string[] }>}
138
+ */
139
+ async function encodeImageSequence({ images, password = '', title = '', compress = 2, outputDir = null }) {
140
+ if (!images || images.length === 0) throw new Error('images 数组不能为空');
141
+
142
+ const lsbBits = compress >= 8 ? 8 : compress >= 6 ? 6 : 2;
143
+
144
+ // 第一步:把所有帧转为 PNG 并构建文件头,计算最大所需尺寸
145
+ const pngList = await Promise.all(images.map(img => toPngBuffer(img)));
146
+ const headerList = pngList.map(png => buildFileHeader(png, password, 'png'));
147
+ const maxBits = Math.max(...headerList.map(h => (h.length + 4) * 8));
148
+ const unifiedSize = requiredCanvasSize(maxBits, lsbBits);
149
+
150
+ // 第二步:逐帧嵌入(使用统一画布尺寸)
151
+ const frames = [];
152
+ const filePaths = [];
153
+
154
+ for (let i = 0; i < pngList.length; i++) {
155
+ const frameTitle = `${title} (${i + 1}/${pngList.length})`;
156
+ const duckPng = await buildDuckImageBuffer(unifiedSize, frameTitle);
157
+ const resultFrame = await embedPayloadLSB(duckPng, headerList[i], lsbBits);
158
+
159
+ frames.push(resultFrame);
160
+
161
+ if (outputDir) {
162
+ const outPath = path.join(outputDir, `duck_seq_${String(i).padStart(5, '0')}.png`);
163
+ await fs.promises.mkdir(outputDir, { recursive: true });
164
+ await fs.promises.writeFile(outPath, resultFrame);
165
+ filePaths.push(outPath);
166
+ }
167
+ }
168
+
169
+ return outputDir ? { frames, filePaths } : { frames };
170
+ }
171
+
172
+ /**
173
+ * 把已有的 MP4 文件编码为鸭子图
174
+ * (MP4 字节先转为 binpng 格式再走 LSB 嵌入,与 Python 视频路径对齐)
175
+ *
176
+ * @param {object} opts
177
+ * @param {Buffer|string} opts.mp4Input MP4 Buffer 或文件路径
178
+ * @param {string} [opts.password]
179
+ * @param {string} [opts.title]
180
+ * @param {number} [opts.compress]
181
+ * @param {string} [opts.outputPath]
182
+ * @returns {Promise<{ imageBuffer: Buffer, filePath?: string }>}
183
+ */
184
+ async function encodeMp4({ mp4Input, password = '', title = '', compress = 2, outputPath = null }) {
185
+ let mp4Bytes;
186
+ if (typeof mp4Input === 'string') {
187
+ mp4Bytes = await fs.promises.readFile(mp4Input);
188
+ } else if (Buffer.isBuffer(mp4Input)) {
189
+ mp4Bytes = mp4Input;
190
+ } else {
191
+ throw new TypeError('mp4Input 必须是 Buffer 或文件路径');
192
+ }
193
+
194
+ // MP4 字节 → binpng PNG Buffer(中间存储格式)
195
+ const binPng = await bytesToBinaryImage(mp4Bytes, 512);
196
+ const binPngBytes = binPng;
197
+
198
+ const result = await exportDuckPayload({
199
+ rawBytes: binPngBytes,
200
+ password,
201
+ ext: 'mp4.binpng',
202
+ compress,
203
+ title,
204
+ });
205
+
206
+ if (outputPath) {
207
+ await fs.promises.writeFile(outputPath, result.imageBuffer);
208
+ return { imageBuffer: result.imageBuffer, filePath: outputPath };
209
+ }
210
+ return { imageBuffer: result.imageBuffer };
211
+ }
212
+
213
+ module.exports = {
214
+ encodeImage,
215
+ encodeText,
216
+ encodeBytes,
217
+ encodeImageSequence,
218
+ encodeMp4,
219
+ };