// Copyright (c) 未来飞马 // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // Trademark Notice: // The MPL-2.0 license grants copyright permissions for source code only. // It does NOT grant any rights to use trademarks including "未来飞马", // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步". // Any use of these trademarks requires separate written permission. /** * 素材分组与阅读顺序 * * 同事连发 6 张 / 9 张图,**不是多个素材,而是同一个素材**(朋友圈九宫格)。 * 这里用三种信号判定「是否属于同一组」,并给出显式 order: * ① 尺寸(宽高比)——九宫格同一批图通常同源同比例; * ② 时间(mtime / 收图时间)——同一批连发的时间戳接近; * ③ 内容相似度——OCR 文本 + 描述的词/字面重叠度。 * 任两种信号一致即归为一组;只有一种信号可用时按该信号判定,并在 reasons 里说明。 * * 顺序:同组内按「九宫格阅读顺序」= 左上 → 右下 = 从左到右、从上到下。 * 由于原始排版位置通常已经丢失,用「收图时间升序 + 文件名自然序」近似还原发送顺序, * 并显式写出 orderingRule,供人工在 materialAssets[] 上再调整(reorderAssets)。 */ import { uniq, truncate } from './lib.mjs'; export const DEFAULT_ORDERING_RULE = 'left-to-right,top-to-bottom'; // --------------------------------------------------------------------------- // 相似度 // --------------------------------------------------------------------------- function bigrams(text) { const clean = String(text || '').replace(/\s+/g, '').toLowerCase(); if (clean.length < 2) return clean ? new Set([clean]) : new Set(); const out = new Set(); for (let i = 0; i < clean.length - 1; i++) out.add(clean.slice(i, i + 2)); return out; } /** 字符二元组 Jaccard 相似度(0–1)。中文、英文都适用,无需分词。 */ export function textSimilarity(a, b) { const setA = bigrams(a); const setB = bigrams(b); if (!setA.size || !setB.size) return 0; let inter = 0; for (const g of setA) if (setB.has(g)) inter += 1; return inter / (setA.size + setB.size - inter); } /** 宽高比相似度(0–1):同源同排版的图接近 1。 */ export function aspectSimilarity(a, b) { const ratioA = Number(a && a.width) > 0 && Number(a && a.height) > 0 ? a.width / a.height : null; const ratioB = Number(b && b.width) > 0 && Number(b && b.height) > 0 ? b.width / b.height : null; if (ratioA === null || ratioB === null) return null; const diff = Math.abs(ratioA - ratioB) / Math.max(ratioA, ratioB); return Math.max(0, 1 - diff); } /** 时间邻近度(0–1):窗口内线性衰减。 */ export function timeProximity(a, b, windowMs) { const ta = Number(a && (a.mtimeMs ?? a.capturedAtMs)); const tb = Number(b && (b.mtimeMs ?? b.capturedAtMs)); if (!Number.isFinite(ta) || !Number.isFinite(tb)) return null; const gap = Math.abs(ta - tb); if (gap >= windowMs) return 0; return 1 - gap / windowMs; } // --------------------------------------------------------------------------- // 排序:把「发送顺序」近似还原成九宫格阅读顺序 // --------------------------------------------------------------------------- function naturalCompare(a, b) { return String(a).localeCompare(String(b), 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }); } function sourceName(item) { const p = item.localPath || item.url || ''; return p.split(/[\\/]/).pop() || p; } /** * 同组内排序:时间优先(同一批连发的时间戳能还原发送顺序), * 时间相同/缺失时回落文件名自然序。 */ export function orderWithinGroup(items) { return items .map((item, index) => ({ item, index })) .sort((a, b) => { const ta = Number(a.item.mtimeMs ?? a.item.capturedAtMs); const tb = Number(b.item.mtimeMs ?? b.item.capturedAtMs); const hasTa = Number.isFinite(ta); const hasTb = Number.isFinite(tb); if (hasTa && hasTb && ta !== tb) return ta - tb; const byName = naturalCompare(sourceName(a.item), sourceName(b.item)); if (byName !== 0) return byName; return a.index - b.index; }) .map((entry) => entry.item); } /** * 九宫格排版推断。 * 9 → 3x3;4 → 2x2;6 → 3x2;2 → 2x1;其它按最接近方阵的因数对。 */ export function inferLayout(count) { const n = Math.max(1, Number(count) || 1); const presets = { 1: [1, 1], 2: [2, 1], 3: [3, 1], 4: [2, 2], 6: [3, 2], 9: [3, 3], 12: [4, 3] }; if (presets[n]) return `${presets[n][0]}x${presets[n][1]}`; for (let cols = Math.ceil(Math.sqrt(n)); cols <= n; cols++) { if (n % cols === 0) return `${cols}x${n / cols}`; } return `${n}x1`; } // --------------------------------------------------------------------------- // 分组 // --------------------------------------------------------------------------- const DEFAULTS = { timeWindowMs: 15 * 60 * 1000, // 连发 15 分钟内算同一批 aspectThreshold: 0.88, // 宽高比相似度阈值 textThreshold: 0.25, // 文本相似度阈值(同批截图常有共同话术) }; /** * 把散图切成「素材组」。 * * @param {Array} items 每项:{localPath|url, kind, width, height, mtimeMs, ocrText, description} * @param {object} [options] timeWindowMs / aspectThreshold / textThreshold / groupIdPrefix * @returns {Array<{groupId, layout, count, orderingRule, kind, signals, reasons, items}>} */ export function groupMaterials(items, options = {}) { const opts = { ...DEFAULTS, ...options }; const list = (items || []).filter(Boolean).map((item, index) => ({ kind: 'image', ...item, _index: index, })); if (!list.length) return []; const groups = []; for (const item of list) { let best = null; let bestScore = -1; let bestReasons = []; for (const group of groups) { const { score, reasons } = groupAffinity(group, item, opts); if (score > bestScore) { best = group; bestScore = score; bestReasons = reasons; } } if (best && bestScore >= 0.5) { best.items.push(item); best.reasons = uniq([...best.reasons, ...bestReasons]); continue; } groups.push({ groupId: `${opts.groupIdPrefix || 'g'}${groups.length + 1}`, items: [item], reasons: ['seed'], signals: [], }); } // 组内排序 + 排版 + 显式 order return groups.map((group) => { const ordered = orderWithinGroup(group.items); const withOrder = ordered.map((item, index) => ({ ...item, groupId: group.groupId, order: index + 1, })); const kind = withOrder.every((i) => i.kind === 'video') ? 'video' : 'image'; const signals = uniq(withOrder.flatMap((i) => i._signals || [])); return { groupId: group.groupId, layout: inferLayout(withOrder.length), count: withOrder.length, orderingRule: DEFAULT_ORDERING_RULE, kind, signals, reasons: group.reasons, items: withOrder, preview: withOrder.map((i) => truncate(i.ocrText || i.description || i.label || '', 40)).filter(Boolean).join(' / '), }; }); } /** 单张图与一个已有组的「亲缘度」:≥2 种信号一致 → 强;仅 1 种 → 弱。 */ function groupAffinity(group, item, opts) { const reasons = []; let aspectHits = 0; let aspectTotal = 0; let textBest = 0; let timeBest = 0; for (const member of group.items) { // 来自不同文档的内嵌图永不并组:它们是各自文档的配图,不是同一批发图。 // (同一文档的两张图 sourceKey 相同,仍可正常成组。) if (item.sourceKey && member.sourceKey && item.sourceKey !== member.sourceKey) continue; const aspect = aspectSimilarity(member, item); if (aspect !== null) { aspectTotal += 1; if (aspect >= opts.aspectThreshold) aspectHits += 1; } const text = textSimilarity(member.ocrText || member.description || '', item.ocrText || item.description || ''); if (text > textBest) textBest = text; const time = timeProximity(member, item, opts.timeWindowMs); if (time !== null && time > timeBest) timeBest = time; } const aspectOk = aspectTotal > 0 && aspectHits / aspectTotal >= 0.6; const textOk = textBest >= opts.textThreshold; const timeOk = timeBest > 0; const signals = []; if (aspectOk) signals.push('aspect'); if (textOk) signals.push('text'); if (timeOk) signals.push('time'); if (aspectOk) reasons.push('aspect:同一排版比例'); if (textOk) reasons.push(`text:内容相似度 ${textBest.toFixed(2)}`); if (timeOk) reasons.push(`time:${Math.round((1 - timeBest) * opts.timeWindowMs / 1000)}s 内连发`); // 亲缘分:3 信号 → 0.9;2 信号 → 0.7;1 信号 → 0.4(不足以成组) const score = signals.length >= 3 ? 0.9 : signals.length === 2 ? 0.7 : signals.length === 1 ? 0.4 : 0; item._signals = uniq([...(item._signals || []), ...signals]); return { score, reasons, signals }; } /** * 应用显式分组/顺序覆盖(人工或上游 manifest 指定)。 * override 形如:{ groups: [ { groupId, layout?, orderingRule?, items: [{ localPath|url, order }] } ] } */ export function applyGroupOverrides(groups, override) { if (!override || !Array.isArray(override.groups) || !override.groups.length) return groups; const result = []; const consumed = new Set(); for (const spec of override.groups) { const wanted = new Set((spec.items || []).map((i) => i.localPath || i.url).filter(Boolean)); const collected = []; for (const group of groups) { for (const item of group.items) { const key = item.localPath || item.url; if (wanted.has(key) && !consumed.has(key)) { consumed.add(key); const explicit = (spec.items || []).find((i) => (i.localPath || i.url) === key); collected.push({ ...item, order: Number(explicit && explicit.order) || collected.length + 1 }); } } } if (!collected.length) continue; const ordered = collected.slice().sort((a, b) => a.order - b.order).map((item, index) => ({ ...item, order: index + 1 })); result.push({ groupId: spec.groupId || `g${result.length + 1}`, layout: spec.layout || inferLayout(ordered.length), count: ordered.length, orderingRule: spec.orderingRule || DEFAULT_ORDERING_RULE, kind: ordered.every((i) => i.kind === 'video') ? 'video' : 'image', signals: uniq(ordered.flatMap((i) => i._signals || [])), reasons: ['manual-override'], items: ordered, preview: ordered.map((i) => truncate(i.ocrText || i.description || '', 40)).filter(Boolean).join(' / '), }); } // 没被覆盖到的组,原样保留 for (const group of groups) { const rest = group.items.filter((item) => !consumed.has(item.localPath || item.url)); if (!rest.length) continue; result.push({ ...group, items: rest.map((item, index) => ({ ...item, order: index + 1 })), count: rest.length }); } return result; }