| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285 |
- // 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<object>} 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;
- }
|