@sns-parse/ext-merge 0.1.0-alpha.1

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,10 @@
1
+ /**
2
+ * 图片尺寸探测(无依赖):PNG / GIF / JPEG / WebP 头部解析。
3
+ * 仅供同源切图识别用:只读宽高,不解码像素。
4
+ */
5
+ export interface ImageSize {
6
+ width: number;
7
+ height: number;
8
+ }
9
+ /** 探测图片缓冲区尺寸;无法识别返回 null */
10
+ export declare function probeImageSize(buf: Buffer): ImageSize | null;
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ /**
3
+ * 图片尺寸探测(无依赖):PNG / GIF / JPEG / WebP 头部解析。
4
+ * 仅供同源切图识别用:只读宽高,不解码像素。
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.probeImageSize = probeImageSize;
8
+ /** 探测图片缓冲区尺寸;无法识别返回 null */
9
+ function probeImageSize(buf) {
10
+ if (!buf || buf.length < 12)
11
+ return null;
12
+ // PNG: 89 50 4E 47 0D 0A 1A 0A,IHDR 宽高在 16..24
13
+ if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
14
+ if (buf.length < 24)
15
+ return null;
16
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
17
+ }
18
+ // GIF: GIF8
19
+ if (buf.toString('ascii', 0, 4) === 'GIF8') {
20
+ if (buf.length < 10)
21
+ return null;
22
+ return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
23
+ }
24
+ // JPEG: FF D8,扫描 SOF0/1/2/...(C0-CF,除 C4/C8/CC)段
25
+ if (buf[0] === 0xff && buf[1] === 0xd8) {
26
+ let off = 2;
27
+ while (off + 9 < buf.length) {
28
+ if (buf[off] !== 0xff) {
29
+ off++;
30
+ continue;
31
+ }
32
+ const marker = buf[off + 1];
33
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
34
+ const height = buf.readUInt16BE(off + 5);
35
+ const width = buf.readUInt16BE(off + 7);
36
+ if (width && height)
37
+ return { width, height };
38
+ return null;
39
+ }
40
+ if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd9)) {
41
+ off += 2;
42
+ continue;
43
+ }
44
+ const segLen = buf.readUInt16BE(off + 2);
45
+ if (segLen < 2)
46
+ return null;
47
+ off += 2 + segLen;
48
+ }
49
+ return null;
50
+ }
51
+ // WebP: RIFF....WEBP
52
+ if (buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') {
53
+ const fourcc = buf.toString('ascii', 12, 16);
54
+ if (fourcc === 'VP8 ' && buf.length >= 30) {
55
+ return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };
56
+ }
57
+ if (fourcc === 'VP8L' && buf.length >= 25) {
58
+ const b = buf.readUInt32LE(21);
59
+ return { width: (b & 0x3fff) + 1, height: ((b >> 14) & 0x3fff) + 1 };
60
+ }
61
+ if (fourcc === 'VP8X' && buf.length >= 30) {
62
+ const w = 1 + (buf[24] | (buf[25] << 8) | (buf[26] << 16));
63
+ const h = 1 + (buf[27] | (buf[28] << 8) | (buf[29] << 16));
64
+ return { width: w, height: h };
65
+ }
66
+ return null;
67
+ }
68
+ return null;
69
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @sns-parse/ext-merge:同源切图纯内容识别与合并(依赖 ffmpeg,经 ext-gif 解析)。
3
+ */
4
+ import { mergeImages } from './merge';
5
+ export { mergeImages, candidateLayouts, verifyLayout, detectMergeLayout, xstackLayout, buildMergeFilter, } from './merge';
6
+ export type { MergeLayout } from './merge';
7
+ export { probeImageSize } from './image-size';
8
+ export type { ImageSize } from './image-size';
9
+ /** 组装为 core 扩展片段 */
10
+ export declare function mergeExtension(): {
11
+ mergeImages: typeof mergeImages;
12
+ };
package/lib/index.js ADDED
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.probeImageSize = exports.buildMergeFilter = exports.xstackLayout = exports.detectMergeLayout = exports.verifyLayout = exports.candidateLayouts = exports.mergeImages = void 0;
4
+ exports.mergeExtension = mergeExtension;
5
+ /**
6
+ * @sns-parse/ext-merge:同源切图纯内容识别与合并(依赖 ffmpeg,经 ext-gif 解析)。
7
+ */
8
+ const merge_1 = require("./merge");
9
+ var merge_2 = require("./merge");
10
+ Object.defineProperty(exports, "mergeImages", { enumerable: true, get: function () { return merge_2.mergeImages; } });
11
+ Object.defineProperty(exports, "candidateLayouts", { enumerable: true, get: function () { return merge_2.candidateLayouts; } });
12
+ Object.defineProperty(exports, "verifyLayout", { enumerable: true, get: function () { return merge_2.verifyLayout; } });
13
+ Object.defineProperty(exports, "detectMergeLayout", { enumerable: true, get: function () { return merge_2.detectMergeLayout; } });
14
+ Object.defineProperty(exports, "xstackLayout", { enumerable: true, get: function () { return merge_2.xstackLayout; } });
15
+ Object.defineProperty(exports, "buildMergeFilter", { enumerable: true, get: function () { return merge_2.buildMergeFilter; } });
16
+ var image_size_1 = require("./image-size");
17
+ Object.defineProperty(exports, "probeImageSize", { enumerable: true, get: function () { return image_size_1.probeImageSize; } });
18
+ /** 组装为 core 扩展片段 */
19
+ function mergeExtension() {
20
+ return { mergeImages: merge_1.mergeImages };
21
+ }
package/lib/merge.d.ts ADDED
@@ -0,0 +1,61 @@
1
+ import type { ParserRuntimeLike } from '@sns-parse/core';
2
+ import { type ImageSize } from './image-size';
3
+ export type MergeLayout = {
4
+ kind: 'grid';
5
+ cols: number;
6
+ rows: number;
7
+ } | {
8
+ kind: 'v';
9
+ } | {
10
+ kind: 'h';
11
+ };
12
+ /** 仅凭数量与尺寸给出全部可行布局(内容验证前) */
13
+ export declare function candidateLayouts(n: number, sizes: ImageSize[]): MergeLayout[];
14
+ export interface SeamVerdict {
15
+ /** false = 接缝不可验证(边界带平坦,无纹理可判)或不连续 */
16
+ ok: boolean;
17
+ score: number;
18
+ /** 证据明细(供 debug 日志) */
19
+ dir: 'V' | 'H';
20
+ from: number;
21
+ to: number;
22
+ /** 边界带高频纹理能量(两侧最大值;低于 TEXTURE_MIN 即不可验证) */
23
+ texture: number;
24
+ /** 平滑后接缝差 */
25
+ trendMAD: number;
26
+ /** 片内自然步进基线 */
27
+ baseline: number;
28
+ /** 结论文案 */
29
+ reason: string;
30
+ }
31
+ /** 内容验证:所有接缝(趋势+纹理)均通过才成立;返回最大接缝比值与逐缝证据 */
32
+ export declare function verifyLayout(layout: MergeLayout, grays: Buffer[]): {
33
+ pass: boolean;
34
+ score: number;
35
+ seams: SeamVerdict[];
36
+ };
37
+ /** 内容识别:候选布局逐一经接缝连续性验证,取通过者中比值最小的一个 */
38
+ export declare function detectMergeLayout(sizes: ImageSize[], grays: Buffer[]): MergeLayout | null;
39
+ /** 布局名(日志用) */
40
+ export declare function layoutName(layout: MergeLayout): string;
41
+ /**
42
+ * 候选布局逐一验证并产出证据链;返回通过者中证据最强(worst 接缝比值最小)的一个。
43
+ * 每次调用都会把逐候选、逐接缝的判定依据写入 debug 日志(含尺寸与灰度行数)。
44
+ */
45
+ export declare function pickMergeLayout(sizes: ImageSize[], grays: Buffer[]): {
46
+ layout: MergeLayout;
47
+ score: number;
48
+ seams: SeamVerdict[];
49
+ } | null;
50
+ /** xstack 均匀网格布局串(各片已统一缩放为 w×h,行优先) */
51
+ export declare function xstackLayout(cols: number, rows: number, w: number, h: number): string;
52
+ /** 生成 ffmpeg filter_complex(输出标签恒为 [out],导出供测试) */
53
+ export declare function buildMergeFilter(n: number, layout: MergeLayout, sizes: ImageSize[]): string;
54
+ /**
55
+ * 下载 → 灰度解码 → 内容识别接缝 → 合并。
56
+ * 不构成母图切片(接缝不连续)或任一环节失败 → null。
57
+ */
58
+ export declare function mergeImages(rt: ParserRuntimeLike, urls: string[]): Promise<{
59
+ buffer: Buffer;
60
+ layout: MergeLayout;
61
+ } | null>;
package/lib/merge.js ADDED
@@ -0,0 +1,388 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.candidateLayouts = candidateLayouts;
4
+ exports.verifyLayout = verifyLayout;
5
+ exports.detectMergeLayout = detectMergeLayout;
6
+ exports.layoutName = layoutName;
7
+ exports.pickMergeLayout = pickMergeLayout;
8
+ exports.xstackLayout = xstackLayout;
9
+ exports.buildMergeFilter = buildMergeFilter;
10
+ exports.mergeImages = mergeImages;
11
+ /**
12
+ * 同源切图识别与合并(纯内容识别,不依赖 URL/文件名等外部信息):
13
+ * - 候选布局:n∈{4,9,16} 且各片等尺寸 → n×n 宫格;各片同宽 → 垂直堆叠;各片同高 → 水平拼接
14
+ * - 内容验证:ffmpeg 解码为 256 宽灰度图,比较接缝两侧边界行/列的平均绝对差(MAD),
15
+ * 与图片内部相邻行/列的自然差异比较——母图切片在接缝处连续(比值≈1),无关图片差异显著(比值≫1)
16
+ * - 合并:宫格 xstack / 垂直 vstack / 水平 hstack
17
+ *
18
+ * 识别/解码/ffmpeg 任一环节失败均返回 null,调用方回退逐张发送。
19
+ */
20
+ const child_process_1 = require("child_process");
21
+ const crypto_1 = require("crypto");
22
+ const promises_1 = require("fs/promises");
23
+ const os_1 = require("os");
24
+ const path_1 = require("path");
25
+ const image_size_1 = require("./image-size");
26
+ const ext_gif_1 = require("@sns-parse/ext-gif");
27
+ const core_1 = require("@sns-parse/core");
28
+ const MAX_PIECES = 12;
29
+ /** 同一母图切出的分片尺寸精确相等,仅留极小容差防探测/编解码舍入 */
30
+ const DIM_TOLERANCE = 2;
31
+ const MAX_OUTPUT_SIDE = 4096;
32
+ const MAX_TOTAL_BYTES = 150 * 1048576;
33
+ /** 灰度归一化宽度(边界行/列向量长度基准) */
34
+ const GRAY_W = 256;
35
+ /** 列向量统一重采样长度 */
36
+ const COL_LEN = 64;
37
+ /** 接缝差异 / 内部自然差异 的判定阈值(母图切片 ≈1,无关图片 ≫1;下限 3 防平坦图除零) */
38
+ const BOUNDARY_THRESH = 3.0;
39
+ const MAD_FLOOR = 3.0;
40
+ /** 边界带纹理能量下限:低于此值视为「不可验证接缝」(相似背景/平边照片会骗过亮度对比) */
41
+ const TEXTURE_MIN = 4.0;
42
+ /* ---------- 尺寸候选 ---------- */
43
+ function dimsEqual(a, b, tol) {
44
+ return Math.abs(a.width - b.width) <= tol && Math.abs(a.height - b.height) <= tol;
45
+ }
46
+ /** 仅凭数量与尺寸给出全部可行布局(内容验证前) */
47
+ function candidateLayouts(n, sizes) {
48
+ if (n < 2 || n > MAX_PIECES)
49
+ return [];
50
+ if (sizes.some((s) => !s || s.width < 8 || s.height < 8))
51
+ return [];
52
+ const out = [];
53
+ const sqrt = Math.sqrt(n);
54
+ if (Number.isInteger(sqrt) && sizes.every((s) => dimsEqual(s, sizes[0], DIM_TOLERANCE))) {
55
+ out.push({ kind: 'grid', cols: sqrt, rows: sqrt });
56
+ }
57
+ if (sizes.every((s) => Math.abs(s.width - sizes[0].width) <= DIM_TOLERANCE))
58
+ out.push({ kind: 'v' });
59
+ if (sizes.every((s) => Math.abs(s.height - sizes[0].height) <= DIM_TOLERANCE))
60
+ out.push({ kind: 'h' });
61
+ return out;
62
+ }
63
+ /* ---------- 灰度边界向量 ---------- */
64
+ /** 解码为固定宽度灰度原始流(buf = rows × GRAY_W 字节) */
65
+ function toGray(file) {
66
+ return new Promise((resolve, reject) => {
67
+ const child = (0, child_process_1.spawn)((0, ext_gif_1.resolveFfmpeg)(), [
68
+ '-hide_banner', '-loglevel', 'error', '-i', file,
69
+ '-vf', `scale=${GRAY_W}:-2:flags=area,format=gray`, '-f', 'rawvideo', 'pipe:1',
70
+ ]);
71
+ const chunks = [];
72
+ child.stdout.on('data', (d) => chunks.push(d));
73
+ child.stderr.on('data', (d) => reject(new Error(d.toString().slice(0, 120))));
74
+ child.on('error', (e) => reject(e));
75
+ child.on('close', (code) => {
76
+ const buf = Buffer.concat(chunks);
77
+ if (code === 0 && buf.length >= GRAY_W * 2)
78
+ resolve(buf);
79
+ else
80
+ reject(new Error(`gray decode exit ${code}`));
81
+ });
82
+ });
83
+ }
84
+ function grayRows(g) {
85
+ return Math.floor(g.length / GRAY_W);
86
+ }
87
+ /** 取一行(256 长) */
88
+ function rowOf(g, r) {
89
+ const rows = grayRows(g);
90
+ const rr = Math.min(Math.max(r, 0), rows - 1);
91
+ const out = new Float32Array(GRAY_W);
92
+ for (let x = 0; x < GRAY_W; x++)
93
+ out[x] = g[rr * GRAY_W + x];
94
+ return out;
95
+ }
96
+ /** 取一列(重采样到 COL_LEN,适配各片灰度高度不一) */
97
+ function colOf(g, c) {
98
+ const rows = grayRows(g);
99
+ const cc = Math.min(Math.max(c, 0), GRAY_W - 1);
100
+ const out = new Float32Array(COL_LEN);
101
+ for (let i = 0; i < COL_LEN; i++) {
102
+ const y = Math.min(rows - 1, Math.round((i / (COL_LEN - 1)) * (rows - 1)));
103
+ out[i] = g[y * GRAY_W + cc];
104
+ }
105
+ return out;
106
+ }
107
+ function mad(a, b) {
108
+ let sum = 0;
109
+ for (let i = 0; i < a.length; i++)
110
+ sum += Math.abs(a[i] - b[i]);
111
+ return sum / a.length;
112
+ }
113
+ /** 滑动平均(提取平滑趋势) */
114
+ function smooth(v) {
115
+ const win = Math.max(3, Math.floor(v.length / 16) | 1);
116
+ const out = new Float32Array(v.length);
117
+ let sum = 0;
118
+ for (let i = 0; i < v.length; i++) {
119
+ sum += v[i];
120
+ if (i >= win)
121
+ sum -= v[i - win];
122
+ out[i] = sum / Math.min(i + 1, win);
123
+ }
124
+ return out;
125
+ }
126
+ /** 去趋势残差(高频纹理成分) */
127
+ function residual(v) {
128
+ const s = smooth(v);
129
+ const out = new Float32Array(v.length);
130
+ for (let i = 0; i < v.length; i++)
131
+ out[i] = v[i] - s[i];
132
+ return out;
133
+ }
134
+ function std(v) {
135
+ let mean = 0;
136
+ for (let i = 0; i < v.length; i++)
137
+ mean += v[i];
138
+ mean /= v.length;
139
+ let acc = 0;
140
+ for (let i = 0; i < v.length; i++)
141
+ acc += (v[i] - mean) ** 2;
142
+ return Math.sqrt(acc / v.length);
143
+ }
144
+ /**
145
+ * 单条接缝裁决:
146
+ * - 可验证性:任一侧边界带高频纹理能量不足(TEXTURE_MIN)→ 拒绝(平坦边缘无法证明连续)
147
+ * - 连续性:低频成分(平滑后)的边界差 vs 片内相邻行/列的自然步进,比值 < 阈值才通过。
148
+ * 高频纹理逐行天然去相关、不能作匹配信号,先平滑剔除;基线取各自片内相邻对
149
+ * (不可跨片比较,否则无关片间差异会稀释接缝信号)。
150
+ */
151
+ function judgeSeam(edgeA, edgeB, dir, from, to, innerPairs) {
152
+ const texture = Math.max(std(residual(edgeA)), std(residual(edgeB)));
153
+ const mk = (ok, score, reason, trendMAD = 0, baseline = 0) => ({ ok, score, dir, from, to, texture, trendMAD, baseline, reason });
154
+ if (texture < TEXTURE_MIN)
155
+ return mk(false, Infinity, `纹理能量 ${texture.toFixed(1)} < ${TEXTURE_MIN},平坦边缘不可验证`);
156
+ const sA = smooth(edgeA);
157
+ const sB = smooth(edgeB);
158
+ const pairs = innerPairs();
159
+ const innerTrend = pairs.map(([p, q]) => mad(smooth(p), smooth(q)));
160
+ const baseline = innerTrend.reduce((t, s) => t + s, 0) / innerTrend.length;
161
+ const trendRef = Math.max(baseline, MAD_FLOOR);
162
+ const trendMAD = mad(sA, sB);
163
+ const score = trendMAD / trendRef;
164
+ return mk(score < BOUNDARY_THRESH, score, score < BOUNDARY_THRESH
165
+ ? `连续(趋势差 ${trendMAD.toFixed(1)} / 基线 ${trendRef.toFixed(1)} = ${score.toFixed(2)} < ${BOUNDARY_THRESH},纹理 ${texture.toFixed(1)})`
166
+ : `不连续(趋势差 ${trendMAD.toFixed(1)} / 基线 ${trendRef.toFixed(1)} = ${score.toFixed(2)} ≥ ${BOUNDARY_THRESH},纹理 ${texture.toFixed(1)})`, trendMAD, baseline);
167
+ }
168
+ /** 垂直接缝:A 末行 vs B 首行(基线取各自片内相邻行对) */
169
+ function seamVerdictV(a, b, from, to) {
170
+ const r = grayRows(a);
171
+ return judgeSeam(rowOf(a, r - 1), rowOf(b, 0), 'V', from, to, () => [
172
+ [rowOf(a, r - 2), rowOf(a, r - 1)],
173
+ [rowOf(a, r - 3), rowOf(a, r - 2)],
174
+ [rowOf(b, 1), rowOf(b, 0)],
175
+ [rowOf(b, 2), rowOf(b, 1)],
176
+ ]);
177
+ }
178
+ /** 水平接缝:A 末列 vs B 首列(基线取各自片内相邻列对) */
179
+ function seamVerdictH(a, b, from, to) {
180
+ return judgeSeam(colOf(a, GRAY_W - 1), colOf(b, 0), 'H', from, to, () => [
181
+ [colOf(a, GRAY_W - 2), colOf(a, GRAY_W - 1)],
182
+ [colOf(a, GRAY_W - 3), colOf(a, GRAY_W - 2)],
183
+ [colOf(b, 1), colOf(b, 0)],
184
+ [colOf(b, 2), colOf(b, 1)],
185
+ ]);
186
+ }
187
+ /** 内容验证:所有接缝(趋势+纹理)均通过才成立;返回最大接缝比值与逐缝证据 */
188
+ function verifyLayout(layout, grays) {
189
+ const seams = [];
190
+ const n = grays.length;
191
+ if (layout.kind === 'v') {
192
+ for (let i = 0; i + 1 < n; i++)
193
+ seams.push(seamVerdictV(grays[i], grays[i + 1], i, i + 1));
194
+ }
195
+ else if (layout.kind === 'h') {
196
+ for (let i = 0; i + 1 < n; i++)
197
+ seams.push(seamVerdictH(grays[i], grays[i + 1], i, i + 1));
198
+ }
199
+ else {
200
+ const { cols } = layout;
201
+ for (let i = 0; i < n; i++) {
202
+ if (i % cols < cols - 1 && i + 1 < n)
203
+ seams.push(seamVerdictH(grays[i], grays[i + 1], i, i + 1));
204
+ if (i + cols < n)
205
+ seams.push(seamVerdictV(grays[i], grays[i + cols], i, i + cols));
206
+ }
207
+ }
208
+ if (!seams.length)
209
+ return { pass: false, score: Infinity, seams };
210
+ const worst = Math.max(...seams.map((v) => v.score));
211
+ return { pass: seams.every((v) => v.ok), score: worst, seams };
212
+ }
213
+ /** 内容识别:候选布局逐一经接缝连续性验证,取通过者中比值最小的一个 */
214
+ function detectMergeLayout(sizes, grays) {
215
+ return pickMergeLayout(sizes, grays)?.layout ?? null;
216
+ }
217
+ /** 布局名(日志用) */
218
+ function layoutName(layout) {
219
+ return layout.kind === 'grid' ? `网格 ${layout.cols}x${layout.rows}` : layout.kind === 'v' ? '竖堆(水平切分)' : '横拼(垂直切分)';
220
+ }
221
+ /**
222
+ * 候选布局逐一验证并产出证据链;返回通过者中证据最强(worst 接缝比值最小)的一个。
223
+ * 每次调用都会把逐候选、逐接缝的判定依据写入 debug 日志(含尺寸与灰度行数)。
224
+ */
225
+ function pickMergeLayout(sizes, grays) {
226
+ const candidates = candidateLayouts(sizes.length, sizes);
227
+ (0, core_1.debugLog)(`同源合并候选:${sizes.length} 张,尺寸 ${sizes.map((s) => `${s.width}x${s.height}`).join(' / ')},灰度行数 ${grays.map((g) => grayRows(g)).join('/')}`);
228
+ if (!candidates.length) {
229
+ (0, core_1.debugLog)('同源合并候选:无(数量/尺寸组合不构成网格/竖堆/横拼任何可能)');
230
+ return null;
231
+ }
232
+ let best = null;
233
+ for (const c of candidates) {
234
+ const { pass, score, seams } = verifyLayout(c, grays);
235
+ const detail = seams.map((v) => `[${v.dir === 'V' ? '竖' : '横'}缝 ${v.from}→${v.to}] ${v.reason}`).join(';');
236
+ (0, core_1.debugLog)(`同源合并证据·${layoutName(c)}:${pass ? '通过' : '拒绝'},worst=${score === Infinity ? '∞' : score.toFixed(2)} ⟶ ${detail}`);
237
+ if (pass && (!best || score < best.score))
238
+ best = { layout: c, score, seams };
239
+ }
240
+ if (best)
241
+ (0, core_1.debugLog)(`同源合并裁决:${layoutName(best.layout)}(证据最强,worst 接缝比值 ${best.score.toFixed(2)})`);
242
+ else
243
+ (0, core_1.debugLog)('同源合并裁决:全部候选未通过内容验证 → 逐张发送');
244
+ return best;
245
+ }
246
+ /* ---------- ffmpeg 合并 ---------- */
247
+ /** xstack 均匀网格布局串(各片已统一缩放为 w×h,行优先) */
248
+ function xstackLayout(cols, rows, w, h) {
249
+ const parts = [];
250
+ for (let i = 0; i < cols * rows; i++) {
251
+ const x = (i % cols) * w;
252
+ const y = Math.floor(i / cols) * h;
253
+ parts.push(`${x}_${y}`);
254
+ }
255
+ return parts.join('|');
256
+ }
257
+ /** 生成 ffmpeg filter_complex(输出标签恒为 [out],导出供测试) */
258
+ function buildMergeFilter(n, layout, sizes) {
259
+ const chains = [];
260
+ const stacks = [];
261
+ const chainOf = (stack, outW, outH) => {
262
+ if (outW > MAX_OUTPUT_SIDE || outH > MAX_OUTPUT_SIDE) {
263
+ const scale = Math.min(1, MAX_OUTPUT_SIDE / Math.max(outW, outH));
264
+ return `${stack}[tmp];[tmp]scale=${Math.round(outW * scale) / 2 * 2}:${Math.round(outH * scale) / 2 * 2}:flags=lanczos[out]`;
265
+ }
266
+ return `${stack}[out]`;
267
+ };
268
+ if (layout.kind === 'grid') {
269
+ // 宫格拼图:各片等比缩放进统一格子(不足处黑边补齐——切片场景无补边,杂图集保持各自比例),
270
+ // 逐片 concat 成帧序列后 tile 出宫格(支持非满宫格,如 7 图 3x3)
271
+ const cellW = Math.min(Math.max(...sizes.map((s) => s.width)), 1920);
272
+ const cellH = Math.min(Math.max(...sizes.map((s) => s.height)), 1920);
273
+ const cw = Math.ceil(cellW / 2) * 2;
274
+ const ch = Math.ceil(cellH / 2) * 2;
275
+ for (let i = 0; i < n; i++) {
276
+ chains.push(`[${i}:v]scale=${cw}:${ch}:force_original_aspect_ratio=decrease:flags=lanczos,pad=${cw}:${ch}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1[c${i}]`);
277
+ stacks.push(`[c${i}]`);
278
+ }
279
+ chains.push(`${stacks.join('')}concat=n=${n}:v=1:a=0[frames]`);
280
+ chains.push(chainOf(`[frames]tile=${layout.cols}x${layout.rows}`, layout.cols * cw, layout.rows * ch));
281
+ }
282
+ else if (layout.kind === 'v') {
283
+ const w = Math.max(...sizes.map((s) => s.width));
284
+ for (let i = 0; i < n; i++)
285
+ chains.push(`[${i}:v]scale=${w}:-2:flags=lanczos[s${i}]`);
286
+ for (let i = 0; i < n; i++)
287
+ stacks.push(`[s${i}]`);
288
+ chains.push(chainOf(`${stacks.join('')}vstack=inputs=${n}`, w, sizes.reduce((t, s) => t + s.height, 0)));
289
+ }
290
+ else {
291
+ const h = Math.max(...sizes.map((s) => s.height));
292
+ for (let i = 0; i < n; i++)
293
+ chains.push(`[${i}:v]scale=-2:${h}:flags=lanczos[s${i}]`);
294
+ for (let i = 0; i < n; i++)
295
+ stacks.push(`[s${i}]`);
296
+ chains.push(chainOf(`${stacks.join('')}hstack=inputs=${n}`, sizes.reduce((t, s) => t + s.width, 0), h));
297
+ }
298
+ return chains.join(';');
299
+ }
300
+ /** 按魔数给临时文件配扩展名(ffmpeg 也可自行探测,显式更稳) */
301
+ function extOf(buf) {
302
+ if (buf[0] === 0x89 && buf[1] === 0x50)
303
+ return 'png';
304
+ if (buf[0] === 0xff && buf[1] === 0xd8)
305
+ return 'jpg';
306
+ if (buf.toString('ascii', 0, 4) === 'RIFF')
307
+ return 'webp';
308
+ if (buf.toString('ascii', 0, 4) === 'GIF8')
309
+ return 'gif';
310
+ return 'bin';
311
+ }
312
+ const MERGE_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
313
+ function downloadImage(rt, url) {
314
+ const headers = { 'User-Agent': MERGE_UA };
315
+ headers['Referer'] = /twimg\.com/.test(url) ? 'https://twitter.com/' : 'https://www.baidu.com/';
316
+ return rt.http.get(url, { responseType: 'arraybuffer', timeout: 30000, headers })
317
+ .then((res) => Buffer.from(res.data));
318
+ }
319
+ /**
320
+ * 下载 → 灰度解码 → 内容识别接缝 → 合并。
321
+ * 不构成母图切片(接缝不连续)或任一环节失败 → null。
322
+ */
323
+ async function mergeImages(rt, urls) {
324
+ if (urls.length < 2 || urls.length > MAX_PIECES)
325
+ return null;
326
+ let buffers;
327
+ try {
328
+ buffers = await Promise.all(urls.map((u) => downloadImage(rt, u)));
329
+ }
330
+ catch (e) {
331
+ (0, core_1.debugLog)(`切图合并跳过(分片下载失败):${e?.message || e}`);
332
+ return null;
333
+ }
334
+ const total = buffers.reduce((t, b) => t + b.length, 0);
335
+ if (total > MAX_TOTAL_BYTES || buffers.some((b) => !b.length))
336
+ return null;
337
+ const sizes = buffers.map((b) => (0, image_size_1.probeImageSize)(b));
338
+ if (sizes.some((s) => !s))
339
+ return null;
340
+ const dir = await (0, promises_1.mkdtemp)((0, path_1.join)((0, os_1.tmpdir)(), `vpa-merge-${(0, crypto_1.randomBytes)(4).toString('hex')}-`));
341
+ try {
342
+ const files = [];
343
+ for (let i = 0; i < buffers.length; i++) {
344
+ const f = (0, path_1.join)(dir, `${i}.${extOf(buffers[i])}`);
345
+ await (0, promises_1.writeFile)(f, buffers[i]);
346
+ files.push(f);
347
+ }
348
+ // 布局决策(完全由像素内容裁决,不依赖数量规则、链接/文件名与色调风格):
349
+ // 网格/竖堆/横拼三种布局候选(按尺寸可能性给出),逐一经接缝内容验证
350
+ // (亮度趋势 + 去趋势纹理双通道、平坦边缘不可验证即拒绝),
351
+ // 取通过者中证据最强(接缝比值最小)的一种——真 2x2/3x3 切片网格缝连续、
352
+ // 长图条带单向连续、无关图全部不过;合并方式由内容区分,均不过则逐张发送
353
+ let grays;
354
+ try {
355
+ grays = await Promise.all(files.map(toGray));
356
+ }
357
+ catch (e) {
358
+ (0, core_1.debugLog)(`切图合并跳过(灰度解码失败):${e?.message || e}`);
359
+ return null;
360
+ }
361
+ const picked = pickMergeLayout(sizes, grays);
362
+ if (!picked)
363
+ return null;
364
+ const layout = picked.layout;
365
+ const filter = buildMergeFilter(files.length, layout, sizes);
366
+ const args = ['-hide_banner', '-loglevel', 'error', ...files.flatMap((f) => ['-i', f]),
367
+ '-filter_complex', filter, '-map', '[out]', '-frames:v', '1', '-q:v', '2',
368
+ '-f', 'image2pipe', '-c:v', 'mjpeg', 'pipe:1'];
369
+ const out = await new Promise((resolve) => {
370
+ const child = (0, child_process_1.spawn)((0, ext_gif_1.resolveFfmpeg)(), args);
371
+ const chunks = [];
372
+ child.stdout.on('data', (d) => chunks.push(d));
373
+ child.stderr.on('data', () => { });
374
+ child.on('error', () => resolve(null));
375
+ child.on('close', (code) => resolve(code === 0 && chunks.length ? Buffer.concat(chunks) : null));
376
+ });
377
+ if (!out) {
378
+ (0, core_1.debugLog)(`切图合并失败(ffmpeg 退出非 0),回退逐张发送`);
379
+ return null;
380
+ }
381
+ const desc = layout.kind === 'grid' ? `${layout.cols}x${layout.rows} 宫格` : layout.kind === 'v' ? '垂直堆叠' : '水平拼接';
382
+ core_1.logger.info(`同源切图已合并为一张(内容识别:${urls.length} 张 → ${desc},${Math.round(out.length / 1024)}KB)`);
383
+ return { buffer: out, layout };
384
+ }
385
+ finally {
386
+ (0, promises_1.rm)(dir, { recursive: true, force: true }).catch(() => { });
387
+ }
388
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@sns-parse/ext-merge",
3
+ "version": "0.1.0-alpha.1+upstream.1.6.7",
4
+ "description": "sns-parse 扩展:merge",
5
+ "main": "lib/index.js",
6
+ "types": "lib/index.d.ts",
7
+ "files": [
8
+ "lib"
9
+ ],
10
+ "license": "MIT",
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "clean": "rimraf lib",
14
+ "prepublishOnly": "npm run build"
15
+ },
16
+ "dependencies": {
17
+ "@sns-parse/core": "^0.1.0",
18
+ "@sns-parse/ext-gif": "^0.1.0-alpha.1"
19
+ },
20
+ "optionalDependencies": {
21
+ "ffmpeg-static": "^5.2.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^20.11.0",
25
+ "rimraf": "^5.0.5",
26
+ "typescript": "^5.3.3"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public",
30
+ "registry": "https://registry.npmjs.org"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/sns-parse/extensions.git",
35
+ "directory": "packages/ext-merge"
36
+ },
37
+ "engines": {
38
+ "node": ">=16.0.0"
39
+ }
40
+ }