Files
memind/mindspace-thumbnails.mjs

524 lines
20 KiB
JavaScript

import fs from 'node:fs/promises';
import path from 'node:path';
import { workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
const FEED_WIDTH = 540;
const FEED_HEIGHT = 720;
const MAX_COVER_BYTES = 1.5 * 1024 * 1024;
const REMOTE_COVER_TIMEOUT_MS = 8000;
function escapeXml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
function titleFromHtml(html) {
const match = String(html).match(/<title[^>]*>([^<]+)<\/title>/i);
return match?.[1]?.trim() ?? '';
}
function h1FromHtml(html) {
const match = String(html).match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
if (!match) return '';
return match[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
}
function descriptionFromHtml(html) {
const meta =
String(html).match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i) ??
String(html).match(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i);
if (meta?.[1]) return meta[1].trim();
const paragraph = String(html).match(/<p[^>]*>([^<]{4,120})/i);
return paragraph?.[1]?.trim() ?? '';
}
function parseCoverMeta(html) {
const tag = String(html).match(/<meta[^>]*name=["']mindspace-cover["'][^>]*>/i)?.[0];
if (!tag) return {};
const contentMatch =
tag.match(/content=(["'])([\s\S]*?)\1/i) ?? tag.match(/content=["']([^"']+)["']/i);
const raw = contentMatch?.[2] ?? contentMatch?.[1];
if (!raw) return {};
try {
return JSON.parse(raw.replaceAll('&quot;', '"'));
} catch {
return {};
}
}
function extractEmoji(text) {
const match = String(text).match(/\p{Extended_Pictographic}/u);
return match?.[0] ?? '';
}
function coverImageFromHtml(html) {
const source = String(html);
const og =
source.match(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i) ??
source.match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i);
if (og?.[1]) return og[1].trim();
const hero =
source.match(/<img[^>]+class=["'][^"']*hero[^"']*["'][^>]+src=["']([^"']+)["']/i) ??
source.match(/<img[^>]+src=["']([^"']+)["'][^>]+class=["'][^"']*hero/i);
if (hero?.[1]) return hero[1].trim();
const first = source.match(/<img[^>]+src=["']([^"']+)["']/i);
return first?.[1]?.trim() ?? '';
}
function svgImageHref(value) {
const raw = String(value);
if (raw.startsWith('data:')) return raw;
return escapeXml(raw);
}
function mimeFromImageBytes(buffer) {
if (buffer[0] === 0xff && buffer[1] === 0xd8) return 'image/jpeg';
if (buffer[0] === 0x89 && buffer[1] === 0x50) return 'image/png';
if (buffer.length >= 12 && buffer.slice(0, 4).toString() === 'RIFF' && buffer.slice(8, 12).toString() === 'WEBP') {
return 'image/webp';
}
if (buffer[0] === 0x47 && buffer[1] === 0x49) return 'image/gif';
return 'image/jpeg';
}
function bytesToDataUri(buffer, mimeType) {
return `data:${mimeType};base64,${buffer.toString('base64')}`;
}
export function bufferToImageDataUri(buffer) {
if (!buffer?.length) return null;
if (buffer.length > MAX_COVER_BYTES) return null;
return bytesToDataUri(buffer, mimeFromImageBytes(buffer));
}
async function readLocalImageDataUri(filePath) {
const buffer = await fs.readFile(filePath);
if (buffer.length === 0 || buffer.length > MAX_COVER_BYTES) return null;
return bytesToDataUri(buffer, mimeFromImageBytes(buffer));
}
async function fetchRemoteImageDataUri(url) {
const response = await fetch(url, {
signal: AbortSignal.timeout(REMOTE_COVER_TIMEOUT_MS),
headers: { Accept: 'image/*' },
redirect: 'follow',
});
if (!response.ok) return null;
const contentType = response.headers.get('content-type') ?? '';
if (contentType && !contentType.startsWith('image/')) return null;
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length === 0 || buffer.length > MAX_COVER_BYTES) return null;
const mimeType = contentType.startsWith('image/') ? contentType.split(';')[0] : mimeFromImageBytes(buffer);
return bytesToDataUri(buffer, mimeType);
}
function resolveLocalImagePath({ storageRoot, contentStorageKey, contentBaseDir, imageUrl }) {
if (!imageUrl || /^data:|^https?:/i.test(imageUrl)) return null;
let baseDir = null;
if (contentStorageKey) {
baseDir = path.dirname(path.resolve(storageRoot, contentStorageKey));
const root = path.resolve(storageRoot);
if (baseDir !== root && !baseDir.startsWith(`${root}${path.sep}`)) return null;
} else if (contentBaseDir) {
baseDir = path.resolve(contentBaseDir);
}
if (!baseDir) return null;
const target = path.resolve(baseDir, imageUrl.replace(/^\.\//, ''));
if (target !== baseDir && !target.startsWith(`${baseDir}${path.sep}`)) return null;
return target;
}
export async function resolveCoverDataUri({
storageRoot,
contentStorageKey,
contentBaseDir,
imageUrl,
resolveAssetDataUri,
}) {
const raw = String(imageUrl ?? '').trim();
if (!raw) return null;
if (raw.startsWith('data:')) return raw.length <= MAX_COVER_BYTES * 2 ? raw : null;
if (/^https?:\/\//i.test(raw)) {
try {
return await fetchRemoteImageDataUri(raw);
} catch {
return null;
}
}
const assetMatch = raw.match(/\/mindspace\/v1\/assets\/([a-z0-9-]+)(?:\/download)?/i);
if (assetMatch && resolveAssetDataUri) {
try {
return await resolveAssetDataUri(assetMatch[1]);
} catch {
return null;
}
}
const localPath = resolveLocalImagePath({ storageRoot, contentStorageKey, contentBaseDir, imageUrl: raw });
if (!localPath) return null;
try {
return await readLocalImageDataUri(localPath);
} catch {
return null;
}
}
function colorsFromHtml(html) {
const colors = [];
const hexRe = /#(?:[0-9a-f]{3}){1,2}\b/gi;
let match;
while ((match = hexRe.exec(String(html))) !== null && colors.length < 6) {
const normalized = normalizeHex(match[0]);
if (!normalized) continue;
if (['#ffffff', '#fff', '#000000', '#000'].includes(normalized)) continue;
if (!colors.includes(normalized)) colors.push(normalized);
}
return colors;
}
function normalizeHex(value) {
const raw = String(value).trim().toLowerCase();
if (!raw.startsWith('#')) return null;
if (raw.length === 4) {
return `#${raw[1]}${raw[1]}${raw[2]}${raw[2]}${raw[3]}${raw[3]}`;
}
if (raw.length === 7) return raw;
return null;
}
function darken(hex, amount = 0.28) {
const color = normalizeHex(hex) ?? '#2f6f57';
const r = Math.round(parseInt(color.slice(1, 3), 16) * (1 - amount));
const g = Math.round(parseInt(color.slice(3, 5), 16) * (1 - amount));
const b = Math.round(parseInt(color.slice(5, 7), 16) * (1 - amount));
return `#${[r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('')}`;
}
function splitTitleLines(title, maxLines = 2) {
const cleaned = String(title || '未命名页面').replace(/\s+/g, ' ').trim();
if (!cleaned) return ['未命名页面'];
if (cleaned.includes('|')) {
return cleaned
.split('|')
.map((part) => part.trim())
.filter(Boolean)
.slice(0, maxLines);
}
if (cleaned.length <= 16) return [cleaned];
const midpoint = Math.ceil(cleaned.length / 2);
const splitAt =
cleaned.lastIndexOf(' ', midpoint) > 8 ? cleaned.lastIndexOf(' ', midpoint) : midpoint;
return [cleaned.slice(0, splitAt).trim(), cleaned.slice(splitAt).trim()].filter(Boolean);
}
function lighten(hex, amount = 0.22) {
const color = normalizeHex(hex) ?? '#2f6f57';
const r = Math.min(255, Math.round(parseInt(color.slice(1, 3), 16) + 255 * amount));
const g = Math.min(255, Math.round(parseInt(color.slice(3, 5), 16) + 255 * amount));
const b = Math.min(255, Math.round(parseInt(color.slice(5, 7), 16) + 255 * amount));
return `#${[r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('')}`;
}
function inferTagFromContent(html, title = '') {
const text = `${title}\n${String(html).slice(0, 8000)}`;
if (/旅行|旅游|攻略|travel/i.test(text)) return '旅行';
if (/美食|餐厅|菜谱|料理|food/i.test(text)) return '美食';
if (/报告|分析|研报|数据|report/i.test(text)) return '报告';
if (/普拉提|瑜伽|健身|运动|pilates|yoga/i.test(text)) return '运动';
if (/618|促销|活动|优惠|限时|大促/i.test(text)) return '活动';
return null;
}
export function shouldUseScenicBackground(signals) {
return /旅行|travel|美食|food|餐|报告|report|分析/i.test(String(signals.tag ?? ''));
}
function resolvePhotoPalette(signals) {
const tag = String(signals.tag ?? '');
const accent = signals.accent;
const accent2 = signals.accent2;
if (/旅行|travel/i.test(tag)) {
return {
sky: '#4f8fb8',
glow: '#f6d7a8',
horizon: '#e39a4d',
land: '#24343a',
shadow: '#0d1518',
flare: '#ffe9c7',
bokeh: '#fff8ef',
};
}
if (/美食|food|餐/i.test(tag)) {
return {
sky: '#5a2b22',
glow: '#ffb27a',
horizon: '#d85f3b',
land: '#241412',
shadow: '#120909',
flare: '#ffd0a8',
bokeh: '#ffe8d6',
};
}
if (/报告|report|分析/i.test(tag)) {
return {
sky: '#3d4f68',
glow: '#9eb4d8',
horizon: '#607892',
land: '#1a2430',
shadow: '#0a1018',
flare: '#c8d8ef',
bokeh: '#eef3fb',
};
}
return {
sky: lighten(accent, 0.18),
glow: lighten(accent, 0.34),
horizon: accent,
land: darken(accent2, 0.1),
shadow: darken(accent2, 0.32),
flare: lighten(accent, 0.42),
bokeh: '#fff8f2',
};
}
export function extractCoverSignals(html, meta = {}) {
const coverMeta = parseCoverMeta(html);
const rawTitle = meta.title || h1FromHtml(html) || titleFromHtml(html) || '未命名页面';
const title = rawTitle.replace(/\p{Extended_Pictographic}/gu, '').replace(/\s+/g, ' ').trim();
const colors = colorsFromHtml(html);
const accent = normalizeHex(coverMeta.accent ?? meta.accent ?? colors[0] ?? '#2f6f57');
const accent2 = normalizeHex(coverMeta.accent2 ?? colors[1] ?? darken(accent, 0.15));
return {
title: title || '未命名页面',
subtitle:
coverMeta.subtitle ??
meta.subtitle ??
descriptionFromHtml(html) ??
'TKMind 作品',
tag:
coverMeta.tag ??
meta.tag ??
inferTagFromContent(html, title) ??
'精选页面',
emoji: coverMeta.emoji ?? meta.emoji ?? extractEmoji(rawTitle) ?? extractEmoji(h1FromHtml(html)),
accent,
accent2,
mood: coverMeta.mood ?? meta.mood ?? 'photo',
image:
coverMeta.cover ??
coverMeta.image ??
meta.cover ??
meta.image ??
coverImageFromHtml(html),
};
}
function themeBackgroundLayers(signals) {
const accent = escapeXml(signals.accent);
const accent2 = escapeXml(signals.accent2);
const glow = escapeXml(lighten(signals.accent, 0.28));
return `<rect width="${FEED_WIDTH}" height="${FEED_HEIGHT}" fill="url(#theme)"/>
<rect width="${FEED_WIDTH}" height="${FEED_HEIGHT}" fill="url(#themeGlow)"/>
<rect width="${FEED_WIDTH}" height="${FEED_HEIGHT}" fill="url(#themeShade)" opacity="0.72"/>`;
}
export function buildFeedThumbnailSvg(signals, options = {}) {
const coverDataUri = options.coverDataUri ?? null;
const hasPhoto = Boolean(coverDataUri);
const useScenic = !hasPhoto && shouldUseScenicBackground(signals);
const palette = resolvePhotoPalette(signals);
const titleLines = splitTitleLines(signals.title);
const line1 = escapeXml(titleLines[0] ?? signals.title).slice(0, 24);
const line2 = escapeXml(titleLines[1] ?? '').slice(0, 24);
const subtitle = escapeXml(signals.subtitle).slice(0, 42);
const tag = escapeXml(signals.tag).slice(0, 12);
const sky = escapeXml(palette.sky);
const glow = escapeXml(palette.glow);
const horizon = escapeXml(palette.horizon);
const land = escapeXml(palette.land);
const shadow = escapeXml(palette.shadow);
const flare = escapeXml(palette.flare);
const bokeh = escapeXml(palette.bokeh);
const titleY2 = line2 ? 652 : 0;
const scenicLayers = useScenic
? `<rect width="${FEED_WIDTH}" height="${FEED_HEIGHT}" fill="url(#sky)"/>
<rect width="${FEED_WIDTH}" height="${FEED_HEIGHT}" fill="url(#sun)"/>
<circle cx="118" cy="168" r="58" fill="${bokeh}" opacity="0.16" filter="url(#blurSoft)"/>
<circle cx="430" cy="248" r="42" fill="${bokeh}" opacity="0.2" filter="url(#blurTight)"/>
<circle cx="360" cy="118" r="26" fill="${flare}" opacity="0.34" filter="url(#blurTight)"/>
<circle cx="72" cy="320" r="18" fill="${bokeh}" opacity="0.28" filter="url(#blurTight)"/>
<path d="M0 470 L90 404 L180 438 L286 372 L392 418 L500 360 L540 388 L540 720 L0 720 Z" fill="${land}" opacity="0.92"/>
<path d="M0 520 L120 468 L250 502 L360 456 L470 492 L540 460 L540 720 L0 720 Z" fill="${shadow}" opacity="0.55"/>`
: !hasPhoto
? themeBackgroundLayers(signals)
: '';
const photoLayer = hasPhoto
? `<image href="${svgImageHref(coverDataUri)}" x="0" y="0" width="${FEED_WIDTH}" height="${FEED_HEIGHT}" preserveAspectRatio="xMidYMid slice"/>`
: '';
const overlayStops = hasPhoto
? `<stop offset="0%" stop-color="#000000" stop-opacity="0.12"/>
<stop offset="45%" stop-color="#000000" stop-opacity="0.02"/>
<stop offset="100%" stop-color="${shadow}" stop-opacity="0.88"/>`
: `<stop offset="0%" stop-color="#000000" stop-opacity="0"/>
<stop offset="52%" stop-color="#000000" stop-opacity="0.08"/>
<stop offset="100%" stop-color="${shadow}" stop-opacity="0.82"/>`;
const vignetteOpacity = hasPhoto ? '0.52' : '0.42';
const grainOpacity = hasPhoto ? '0.18' : '0.28';
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${FEED_WIDTH}" height="${FEED_HEIGHT}" viewBox="0 0 ${FEED_WIDTH} ${FEED_HEIGHT}">
<defs>
<linearGradient id="theme" x1="18%" y1="0%" x2="82%" y2="100%">
<stop offset="0%" stop-color="${escapeXml(lighten(signals.accent, 0.12))}"/>
<stop offset="48%" stop-color="${escapeXml(signals.accent)}"/>
<stop offset="100%" stop-color="${escapeXml(signals.accent2)}"/>
</linearGradient>
<radialGradient id="themeGlow" cx="72%" cy="16%" r="42%">
<stop offset="0%" stop-color="${escapeXml(lighten(signals.accent, 0.42))}" stop-opacity="0.55"/>
<stop offset="100%" stop-color="${escapeXml(signals.accent2)}" stop-opacity="0"/>
</radialGradient>
<linearGradient id="themeShade" x1="50%" y1="55%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#000000" stop-opacity="0"/>
<stop offset="100%" stop-color="#000000" stop-opacity="0.72"/>
</linearGradient>
<linearGradient id="sky" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="${sky}"/>
<stop offset="42%" stop-color="${glow}" stop-opacity="0.88"/>
<stop offset="68%" stop-color="${horizon}"/>
<stop offset="100%" stop-color="${land}"/>
</linearGradient>
<linearGradient id="overlay" x1="50%" y1="0%" x2="50%" y2="100%">
${overlayStops}
</linearGradient>
<radialGradient id="sun" cx="78%" cy="18%" r="34%">
<stop offset="0%" stop-color="${flare}" stop-opacity="0.95"/>
<stop offset="55%" stop-color="${horizon}" stop-opacity="0.24"/>
<stop offset="100%" stop-color="${horizon}" stop-opacity="0"/>
</radialGradient>
<radialGradient id="vignette" cx="50%" cy="48%" r="72%">
<stop offset="55%" stop-color="#000000" stop-opacity="0"/>
<stop offset="100%" stop-color="#000000" stop-opacity="0.42"/>
</radialGradient>
<filter id="blurSoft" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="24"/>
</filter>
<filter id="blurTight" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="10"/>
</filter>
<filter id="grain" x="0%" y="0%" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="3" stitchTiles="stitch"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.14 0"/>
</filter>
</defs>
${photoLayer}
${scenicLayers}
<rect width="${FEED_WIDTH}" height="${FEED_HEIGHT}" fill="url(#overlay)"/>
<rect width="${FEED_WIDTH}" height="${FEED_HEIGHT}" fill="url(#vignette)" opacity="${vignetteOpacity}"/>
<rect width="${FEED_WIDTH}" height="${FEED_HEIGHT}" filter="url(#grain)" opacity="${grainOpacity}"/>
<text x="34" y="42" fill="rgba(255,255,255,0.72)" font-family="ui-sans-serif, system-ui, sans-serif" font-size="11" font-weight="700" letter-spacing="3.2">${tag.toUpperCase()}</text>
<text x="34" y="${line2 ? 612 : 636}" fill="#ffffff" font-family="Georgia, 'Times New Roman', serif" font-size="${line2 ? 34 : 38}" font-weight="600" letter-spacing="-0.4">${line1}</text>
${line2 ? `<text x="34" y="${titleY2}" fill="rgba(255,255,255,0.94)" font-family="Georgia, 'Times New Roman', serif" font-size="28" font-weight="500">${line2}</text>` : ''}
<text x="34" y="${line2 ? 686 : 672}" fill="rgba(255,255,255,0.72)" font-family="ui-sans-serif, system-ui, sans-serif" font-size="14" letter-spacing="0.2">${subtitle}</text>
<text x="506" y="698" text-anchor="end" fill="rgba(255,255,255,0.34)" font-family="ui-sans-serif, system-ui, sans-serif" font-size="10" letter-spacing="2.8">TKMIND</text>
</svg>`;
}
/** @deprecated use buildFeedThumbnailSvg */
export function buildThumbnailSvg({ title, subtitle, accent = '#2f6f57' }) {
return buildFeedThumbnailSvg({
title,
subtitle,
tag: '页面',
emoji: '✦',
accent,
accent2: darken(accent, 0.15),
mood: 'legacy',
});
}
export function assetThumbnailKey(userId, assetId) {
return path.posix.join('users', userId, 'assets', assetId, 'thumbnail.svg');
}
export function pageThumbnailKey(userId, pageId) {
return path.posix.join('users', userId, 'pages', pageId, 'thumbnail.svg');
}
export async function writeThumbnail(storageRoot, storageKey, svg) {
const target = path.resolve(storageRoot, storageKey);
const root = path.resolve(storageRoot);
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
throw new Error('缩略图路径越界');
}
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, svg, 'utf8');
return target;
}
export function isModernFeedThumbnail(svg) {
if (!svg) return false;
return /width="540" height="720"/.test(svg) && /filter id="grain"/.test(svg);
}
export async function generateHtmlThumbnail(storageRoot, storageKey, html, meta = {}) {
const signals = extractCoverSignals(html, meta);
const coverDataUri = await resolveCoverDataUri({
storageRoot,
contentStorageKey: meta.contentStorageKey,
contentBaseDir: meta.contentBaseDir,
imageUrl: signals.image,
resolveAssetDataUri: meta.resolveAssetDataUri,
});
const svg = buildFeedThumbnailSvg(signals, { coverDataUri });
await writeThumbnail(storageRoot, storageKey, svg);
return svg;
}
export async function ensureHtmlThumbnail(storageRoot, storageKey, html, meta = {}) {
const existing = await readThumbnailIfExists(storageRoot, storageKey);
if (existing && isModernFeedThumbnail(existing) && !meta.force) {
return existing;
}
return generateHtmlThumbnail(storageRoot, storageKey, html, meta);
}
export async function ensurePageThumbnail({
storageRoot,
pageThumbnailStorageKey,
html,
meta = {},
workspacePublishDir = null,
workspaceHtmlRelativePath = null,
}) {
if (workspacePublishDir && workspaceHtmlRelativePath) {
const sidecar = await readThumbnailIfExists(
workspacePublishDir,
workspaceThumbnailRelativePath(workspaceHtmlRelativePath),
);
if (sidecar && isModernFeedThumbnail(sidecar)) {
await writeThumbnail(storageRoot, pageThumbnailStorageKey, sidecar);
return sidecar;
}
}
return ensureHtmlThumbnail(storageRoot, pageThumbnailStorageKey, html, meta);
}
export async function readThumbnailIfExists(storageRoot, storageKey) {
try {
return await fs.readFile(path.resolve(storageRoot, storageKey), 'utf8');
} catch {
return null;
}
}
export function scheduleHtmlThumbnail(storageRoot, storageKey, html, meta = {}) {
queueMicrotask(() => {
void ensureHtmlThumbnail(storageRoot, storageKey, html, meta).catch(() => {});
});
}