grouping.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. // Copyright (c) 未来飞马
  2. //
  3. // This Source Code Form is subject to the terms of the Mozilla Public
  4. // License, v. 2.0. If a copy of the MPL was not distributed with this
  5. // file, You can obtain one at https://mozilla.org/MPL/2.0/.
  6. //
  7. // Trademark Notice:
  8. // The MPL-2.0 license grants copyright permissions for source code only.
  9. // It does NOT grant any rights to use trademarks including "未来飞马",
  10. // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
  11. // Any use of these trademarks requires separate written permission.
  12. /**
  13. * 素材分组与阅读顺序
  14. *
  15. * 同事连发 6 张 / 9 张图,**不是多个素材,而是同一个素材**(朋友圈九宫格)。
  16. * 这里用三种信号判定「是否属于同一组」,并给出显式 order:
  17. * ① 尺寸(宽高比)——九宫格同一批图通常同源同比例;
  18. * ② 时间(mtime / 收图时间)——同一批连发的时间戳接近;
  19. * ③ 内容相似度——OCR 文本 + 描述的词/字面重叠度。
  20. * 任两种信号一致即归为一组;只有一种信号可用时按该信号判定,并在 reasons 里说明。
  21. *
  22. * 顺序:同组内按「九宫格阅读顺序」= 左上 → 右下 = 从左到右、从上到下。
  23. * 由于原始排版位置通常已经丢失,用「收图时间升序 + 文件名自然序」近似还原发送顺序,
  24. * 并显式写出 orderingRule,供人工在 materialAssets[] 上再调整(reorderAssets)。
  25. */
  26. import { uniq, truncate } from './lib.mjs';
  27. export const DEFAULT_ORDERING_RULE = 'left-to-right,top-to-bottom';
  28. // ---------------------------------------------------------------------------
  29. // 相似度
  30. // ---------------------------------------------------------------------------
  31. function bigrams(text) {
  32. const clean = String(text || '').replace(/\s+/g, '').toLowerCase();
  33. if (clean.length < 2) return clean ? new Set([clean]) : new Set();
  34. const out = new Set();
  35. for (let i = 0; i < clean.length - 1; i++) out.add(clean.slice(i, i + 2));
  36. return out;
  37. }
  38. /** 字符二元组 Jaccard 相似度(0–1)。中文、英文都适用,无需分词。 */
  39. export function textSimilarity(a, b) {
  40. const setA = bigrams(a);
  41. const setB = bigrams(b);
  42. if (!setA.size || !setB.size) return 0;
  43. let inter = 0;
  44. for (const g of setA) if (setB.has(g)) inter += 1;
  45. return inter / (setA.size + setB.size - inter);
  46. }
  47. /** 宽高比相似度(0–1):同源同排版的图接近 1。 */
  48. export function aspectSimilarity(a, b) {
  49. const ratioA = Number(a && a.width) > 0 && Number(a && a.height) > 0 ? a.width / a.height : null;
  50. const ratioB = Number(b && b.width) > 0 && Number(b && b.height) > 0 ? b.width / b.height : null;
  51. if (ratioA === null || ratioB === null) return null;
  52. const diff = Math.abs(ratioA - ratioB) / Math.max(ratioA, ratioB);
  53. return Math.max(0, 1 - diff);
  54. }
  55. /** 时间邻近度(0–1):窗口内线性衰减。 */
  56. export function timeProximity(a, b, windowMs) {
  57. const ta = Number(a && (a.mtimeMs ?? a.capturedAtMs));
  58. const tb = Number(b && (b.mtimeMs ?? b.capturedAtMs));
  59. if (!Number.isFinite(ta) || !Number.isFinite(tb)) return null;
  60. const gap = Math.abs(ta - tb);
  61. if (gap >= windowMs) return 0;
  62. return 1 - gap / windowMs;
  63. }
  64. // ---------------------------------------------------------------------------
  65. // 排序:把「发送顺序」近似还原成九宫格阅读顺序
  66. // ---------------------------------------------------------------------------
  67. function naturalCompare(a, b) {
  68. return String(a).localeCompare(String(b), 'zh-Hans-CN', { numeric: true, sensitivity: 'base' });
  69. }
  70. function sourceName(item) {
  71. const p = item.localPath || item.url || '';
  72. return p.split(/[\\/]/).pop() || p;
  73. }
  74. /**
  75. * 同组内排序:时间优先(同一批连发的时间戳能还原发送顺序),
  76. * 时间相同/缺失时回落文件名自然序。
  77. */
  78. export function orderWithinGroup(items) {
  79. return items
  80. .map((item, index) => ({ item, index }))
  81. .sort((a, b) => {
  82. const ta = Number(a.item.mtimeMs ?? a.item.capturedAtMs);
  83. const tb = Number(b.item.mtimeMs ?? b.item.capturedAtMs);
  84. const hasTa = Number.isFinite(ta);
  85. const hasTb = Number.isFinite(tb);
  86. if (hasTa && hasTb && ta !== tb) return ta - tb;
  87. const byName = naturalCompare(sourceName(a.item), sourceName(b.item));
  88. if (byName !== 0) return byName;
  89. return a.index - b.index;
  90. })
  91. .map((entry) => entry.item);
  92. }
  93. /**
  94. * 九宫格排版推断。
  95. * 9 → 3x3;4 → 2x2;6 → 3x2;2 → 2x1;其它按最接近方阵的因数对。
  96. */
  97. export function inferLayout(count) {
  98. const n = Math.max(1, Number(count) || 1);
  99. const presets = { 1: [1, 1], 2: [2, 1], 3: [3, 1], 4: [2, 2], 6: [3, 2], 9: [3, 3], 12: [4, 3] };
  100. if (presets[n]) return `${presets[n][0]}x${presets[n][1]}`;
  101. for (let cols = Math.ceil(Math.sqrt(n)); cols <= n; cols++) {
  102. if (n % cols === 0) return `${cols}x${n / cols}`;
  103. }
  104. return `${n}x1`;
  105. }
  106. // ---------------------------------------------------------------------------
  107. // 分组
  108. // ---------------------------------------------------------------------------
  109. const DEFAULTS = {
  110. timeWindowMs: 15 * 60 * 1000, // 连发 15 分钟内算同一批
  111. aspectThreshold: 0.88, // 宽高比相似度阈值
  112. textThreshold: 0.25, // 文本相似度阈值(同批截图常有共同话术)
  113. };
  114. /**
  115. * 把散图切成「素材组」。
  116. *
  117. * @param {Array<object>} items 每项:{localPath|url, kind, width, height, mtimeMs, ocrText, description}
  118. * @param {object} [options] timeWindowMs / aspectThreshold / textThreshold / groupIdPrefix
  119. * @returns {Array<{groupId, layout, count, orderingRule, kind, signals, reasons, items}>}
  120. */
  121. export function groupMaterials(items, options = {}) {
  122. const opts = { ...DEFAULTS, ...options };
  123. const list = (items || []).filter(Boolean).map((item, index) => ({
  124. kind: 'image',
  125. ...item,
  126. _index: index,
  127. }));
  128. if (!list.length) return [];
  129. const groups = [];
  130. for (const item of list) {
  131. let best = null;
  132. let bestScore = -1;
  133. let bestReasons = [];
  134. for (const group of groups) {
  135. const { score, reasons } = groupAffinity(group, item, opts);
  136. if (score > bestScore) {
  137. best = group;
  138. bestScore = score;
  139. bestReasons = reasons;
  140. }
  141. }
  142. if (best && bestScore >= 0.5) {
  143. best.items.push(item);
  144. best.reasons = uniq([...best.reasons, ...bestReasons]);
  145. continue;
  146. }
  147. groups.push({
  148. groupId: `${opts.groupIdPrefix || 'g'}${groups.length + 1}`,
  149. items: [item],
  150. reasons: ['seed'],
  151. signals: [],
  152. });
  153. }
  154. // 组内排序 + 排版 + 显式 order
  155. return groups.map((group) => {
  156. const ordered = orderWithinGroup(group.items);
  157. const withOrder = ordered.map((item, index) => ({
  158. ...item,
  159. groupId: group.groupId,
  160. order: index + 1,
  161. }));
  162. const kind = withOrder.every((i) => i.kind === 'video') ? 'video' : 'image';
  163. const signals = uniq(withOrder.flatMap((i) => i._signals || []));
  164. return {
  165. groupId: group.groupId,
  166. layout: inferLayout(withOrder.length),
  167. count: withOrder.length,
  168. orderingRule: DEFAULT_ORDERING_RULE,
  169. kind,
  170. signals,
  171. reasons: group.reasons,
  172. items: withOrder,
  173. preview: withOrder.map((i) => truncate(i.ocrText || i.description || i.label || '', 40)).filter(Boolean).join(' / '),
  174. };
  175. });
  176. }
  177. /** 单张图与一个已有组的「亲缘度」:≥2 种信号一致 → 强;仅 1 种 → 弱。 */
  178. function groupAffinity(group, item, opts) {
  179. const reasons = [];
  180. let aspectHits = 0;
  181. let aspectTotal = 0;
  182. let textBest = 0;
  183. let timeBest = 0;
  184. for (const member of group.items) {
  185. // 来自不同文档的内嵌图永不并组:它们是各自文档的配图,不是同一批发图。
  186. // (同一文档的两张图 sourceKey 相同,仍可正常成组。)
  187. if (item.sourceKey && member.sourceKey && item.sourceKey !== member.sourceKey) continue;
  188. const aspect = aspectSimilarity(member, item);
  189. if (aspect !== null) {
  190. aspectTotal += 1;
  191. if (aspect >= opts.aspectThreshold) aspectHits += 1;
  192. }
  193. const text = textSimilarity(member.ocrText || member.description || '', item.ocrText || item.description || '');
  194. if (text > textBest) textBest = text;
  195. const time = timeProximity(member, item, opts.timeWindowMs);
  196. if (time !== null && time > timeBest) timeBest = time;
  197. }
  198. const aspectOk = aspectTotal > 0 && aspectHits / aspectTotal >= 0.6;
  199. const textOk = textBest >= opts.textThreshold;
  200. const timeOk = timeBest > 0;
  201. const signals = [];
  202. if (aspectOk) signals.push('aspect');
  203. if (textOk) signals.push('text');
  204. if (timeOk) signals.push('time');
  205. if (aspectOk) reasons.push('aspect:同一排版比例');
  206. if (textOk) reasons.push(`text:内容相似度 ${textBest.toFixed(2)}`);
  207. if (timeOk) reasons.push(`time:${Math.round((1 - timeBest) * opts.timeWindowMs / 1000)}s 内连发`);
  208. // 亲缘分:3 信号 → 0.9;2 信号 → 0.7;1 信号 → 0.4(不足以成组)
  209. const score = signals.length >= 3 ? 0.9 : signals.length === 2 ? 0.7 : signals.length === 1 ? 0.4 : 0;
  210. item._signals = uniq([...(item._signals || []), ...signals]);
  211. return { score, reasons, signals };
  212. }
  213. /**
  214. * 应用显式分组/顺序覆盖(人工或上游 manifest 指定)。
  215. * override 形如:{ groups: [ { groupId, layout?, orderingRule?, items: [{ localPath|url, order }] } ] }
  216. */
  217. export function applyGroupOverrides(groups, override) {
  218. if (!override || !Array.isArray(override.groups) || !override.groups.length) return groups;
  219. const result = [];
  220. const consumed = new Set();
  221. for (const spec of override.groups) {
  222. const wanted = new Set((spec.items || []).map((i) => i.localPath || i.url).filter(Boolean));
  223. const collected = [];
  224. for (const group of groups) {
  225. for (const item of group.items) {
  226. const key = item.localPath || item.url;
  227. if (wanted.has(key) && !consumed.has(key)) {
  228. consumed.add(key);
  229. const explicit = (spec.items || []).find((i) => (i.localPath || i.url) === key);
  230. collected.push({ ...item, order: Number(explicit && explicit.order) || collected.length + 1 });
  231. }
  232. }
  233. }
  234. if (!collected.length) continue;
  235. const ordered = collected.slice().sort((a, b) => a.order - b.order).map((item, index) => ({ ...item, order: index + 1 }));
  236. result.push({
  237. groupId: spec.groupId || `g${result.length + 1}`,
  238. layout: spec.layout || inferLayout(ordered.length),
  239. count: ordered.length,
  240. orderingRule: spec.orderingRule || DEFAULT_ORDERING_RULE,
  241. kind: ordered.every((i) => i.kind === 'video') ? 'video' : 'image',
  242. signals: uniq(ordered.flatMap((i) => i._signals || [])),
  243. reasons: ['manual-override'],
  244. items: ordered,
  245. preview: ordered.map((i) => truncate(i.ocrText || i.description || '', 40)).filter(Boolean).join(' / '),
  246. });
  247. }
  248. // 没被覆盖到的组,原样保留
  249. for (const group of groups) {
  250. const rest = group.items.filter((item) => !consumed.has(item.localPath || item.url));
  251. if (!rest.length) continue;
  252. result.push({ ...group, items: rest.map((item, index) => ({ ...item, order: index + 1 })), count: rest.length });
  253. }
  254. return result;
  255. }