/** * 渲染模块(Koishi 移植版)。 * * 上游链路:ktr(\`@karinjs/template-react\`)把 \`template/**\` 打成 SSR 外壳 → 出 HTML → Karin render 截图。 * Koishi 侧不引入 ktr 的构建管线(需要 vite/tailwind 打包),改为: * 1. 直接用 **react-dom/server** 对上游模板组件做 SSR(模板只依赖 \`{ data, ctx }\` props); * 2. 内联上游构建产物 \`resources/template/style.css\`(tailwind 编译结果,省掉 tailwind 构建); * 3. 用浏览器渲染服务(koishi-plugin-puppeteer / puppeteer-without-canvas)截图;3.5.0 起不再支持 shotkit 内核。 * 路由不在注册表里、或 SSR 失败时,回退到内置通用信息卡片,保证解析结果始终有图。 */ import fs from 'node:fs' import path from 'node:path' import { pathToFileURL } from 'node:url' import { getKoishiContext, logger, segment } from 'node-karin' import { karinPathHtml } from 'node-karin/root' import { PassThrough } from 'node:stream' import React from 'react' import { renderToPipeableStream } from 'react-dom/server' import { importEsm } from '../../../../compat/esm' import { readImageSize } from '../../../../compat/imageSize' import { loadTemplate } from '../../../../ktr/registry' import { Root } from '@/module/utils' import { Config } from '@/module/utils/Config' // 注册 Unicode emoji 图源解析器(emoji-datasource-apple,见 utils/emojiAssets.ts) import '@/module/utils/emojiAssets' import { resolveUseDarkTheme } from './coverTheme' type ImageMetadata = { width?: number; height?: number } /* ------------------------------------------------------------------ * * 样式 * ------------------------------------------------------------------ */ let cachedCss: string | null = null /** 读取并修正模板样式(上游产物里的字体指向 Karin 本地服务,换成相对路径,加载失败则回退系统字体) */ function loadTemplateCss (): string { if (cachedCss !== null) return cachedCss try { const dir = path.resolve(Root.pluginPath, 'resources', 'template') let raw = fs.readFileSync(path.join(dir, 'style.css'), 'utf8') /** * style.css 是 tailwind v4 的**按需产物**,只含构建那一刻扫描到的类。 * 模板跟着上游更新后,新引入的工具类不会自动出现 —— 页面不报错,只是静默掉间距、 * 掉尺寸、图标塌成 0 宽(实况照片提示卡就这么崩过)。 * scripts/sync-template-css.mjs 把上游产物里对应的规则抽到 extra-utilities.css,这里追加。 */ try { raw += '\n' + fs.readFileSync(path.join(dir, 'extra-utilities.css'), 'utf8') } catch { // 没有这个文件就是没同步过,按原样用 style.css } cachedCss = raw.replace(/url\("?http:\/\/localhost:\d+\/config\/commonResource\//g, 'url("./template-fonts/') } catch (error: any) { logger.debug('[Render] 读取模板样式失败,将使用无样式渲染: ' + String(error?.message ?? error)) cachedCss = '' } return cachedCss } /* ------------------------------------------------------------------ * * 兜底卡片 * ------------------------------------------------------------------ */ const escapeHtml = (input: unknown): string => String(input ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') /** 递归时不往里看的键(Koishi 的 ctx / 适配器实例等,读属性会触发「未注册」警告且毫无意义) */ const PICK_SKIP_KEYS = new Set([ 'ctx', 'app', 'root', 'scope', 'bot', 'session', 'event', 'logger', 'config', '_events', '_eventsCount', 'adapter', 'http', 'internal', 'socket', 'parser', 'target', 'parent', 'children', 'prototype', 'constructor' ]) /** 只递归纯对象与数组:类实例(ctx / Bot / Session…)一律不深入 */ const isPlainSource = (value: any): boolean => { if (Array.isArray(value)) return true if (!value || typeof value !== 'object') return false const proto = Object.getPrototypeOf(value) return proto === Object.prototype || proto === null } const pickFirst = (source: any, keys: string[], depth = 3, seen: Set = new Set()): any => { if (!source || typeof source !== 'object' || depth < 0) return undefined // 防环(对象互相引用时会无限递归) if (seen.has(source)) return undefined seen.add(source) for (const key of keys) { if (PICK_SKIP_KEYS.has(key)) continue const value = source[key] if (value !== undefined && value !== null && value !== '') return value } for (const [key, value] of Object.entries(source)) { if (PICK_SKIP_KEYS.has(key)) continue if (isPlainSource(value)) { const found = pickFirst(value, keys, depth - 1, seen) if (found !== undefined) return found } } return undefined } const asText = (value: any): string => { if (value === undefined || value === null) return '' if (typeof value === 'string' || typeof value === 'number') return String(value) if (Array.isArray(value)) { const parts = value.map((item) => (item && typeof item === 'object' ? item.text ?? item.desc ?? '' : item)).filter(Boolean) return parts.join('') } return String(value.text ?? value.desc ?? value.title ?? '') } const formatCount = (value: any): string => { const num = Number(value) if (!Number.isFinite(num)) return '' if (num >= 100000000) return (num / 100000000).toFixed(1) + '亿' if (num >= 10000) return (num / 10000).toFixed(1) + '万' return String(num) } /** 从渲染数据里尽力提取通用卡片需要的字段 */ function extractCardData (data: any) { const title = asText(pickFirst(data, ['title', 'desc', 'dynamicTitle', 'name'])) const author = asText(pickFirst(data, ['nickname', 'nick', 'author', 'name', 'uname'])) const avatar = asText(pickFirst(data, ['avatar', 'face', 'avatarUrl', 'head_url'])) const cover = asText(pickFirst(data, ['cover', 'coverUrl', 'originCover', 'pic', 'dynamicCover', 'image'])) const description = asText(pickFirst(data, ['desc', 'description', 'dynamicText', 'summary', 'content'])) const stats = [ ['点赞', pickFirst(data, ['digg_count', 'like', 'likes'])], ['评论', pickFirst(data, ['comment_count', 'comments', 'reply'])], ['收藏', pickFirst(data, ['collect_count', 'favorite', 'favorites'])], ['分享', pickFirst(data, ['share_count', 'share', 'repost'])], ['播放', pickFirst(data, ['play', 'view', 'views', 'playCount'])] ] .map(([label, value]) => [label as string, formatCount(value)] as const) .filter(([, value]) => !!value) return { title, author, avatar, cover, description, stats } } /** 兜底卡片:不依赖模板,直接用通用字段渲染一张信息卡 */ export function buildFallbackHtml (route: string, data: any, dark: boolean, scale: number): string { const card = extractCardData(data) const bg = dark ? '#101014' : '#f4f5f7' const fg = dark ? '#f7f8fa' : '#1c1f23' const sub = dark ? '#a7abb3' : '#6b7280' const coverHtml = card.cover ? '
' : '' const statsHtml = card.stats.length ? '
' + card.stats.map(([label, value]) => '' + escapeHtml(value) + '' + escapeHtml(label) + '').join('') + '
' : '' return '\n\n
\n' + coverHtml + '\n
\n' + '
' + escapeHtml(route) + '
\n' + '
' + escapeHtml(card.title || '解析结果') + '
\n' + (card.author ? '
' + (card.avatar ? '' : '') + '' + escapeHtml(card.author) + '
\n' : '') + (card.description ? '
' + escapeHtml(card.description.slice(0, 600)) + '
\n' : '') + (statsHtml ? ' ' + statsHtml + '\n' : '') + ' \n' + '
\n
' } /* ------------------------------------------------------------------ * * 模板 SSR * ------------------------------------------------------------------ */ /** * 页脚那个「框架 logo」。 * * 上游 karin 是起 HTTP 服务把 `/image/frame-logo.png` 喂给浏览器的; * Koishi 移植版渲染的是**本地 HTML 文件**,绝对路径 `/image/...` 会指向磁盘根目录, * 于是卡片页脚只剩下一个裂图(alt 文字 `logo`)。这里直接内联成 data URI。 */ let frameLogoCache: string | null = null export function resolveFrameLogo (): string { if (frameLogoCache !== null) return frameLogoCache /** * 页脚 logo:用 **Koishi 自己的图标**(@koishijs/plugin-console 里那张, * 也就是控制台左上角那个),而不是上游 karin 的 frame-logo。 */ const candidates = [ path.join(Root.pluginPath, '..', '@koishijs', 'plugin-console', 'dist', 'logo.png'), path.join(Root.pluginPath, '..', '@koishijs', 'client', 'app', 'assets', 'logo.png'), path.join(Root.pluginPath, 'resources', 'image', 'frame-logo.png') ] for (const filePath of candidates) { try { if (!fs.existsSync(filePath)) continue frameLogoCache = 'data:image/png;base64,' + fs.readFileSync(filePath).toString('base64') return frameLogoCache } catch { /* 换下一个 */ } } frameLogoCache = '' return frameLogoCache } /** 组装模板上下文(与上游 Render/index.ts 一致) */ export function buildPosterContext (dark: boolean) { return { scale: Math.min(2, Math.max(0.5, Number(Config.app?.renderScale ?? 100) / 100)), theme: { mode: dark ? ('dark' as const) : ('light' as const) }, version: { plugin: 'koishi-plugin', pluginName: 'kkk', pluginVersion: Root.pluginVersion, releaseType: /^\d+\.\d+\.\d+$/.test(Root.pluginVersion) ? ('Stable' as const) : ('Preview' as const), poweredBy: 'Koishi', frameworkVersion: Root.karinVersion, // 页脚 logo(data URI,见 resolveFrameLogo) frameLogo: resolveFrameLogo(), hasUpdate: false }, ambientCover: { coverOpacity: (Config.app as any)?.ambientCover?.coverOpacity, overlayEdgeOpacity: (Config.app as any)?.ambientCover?.overlayEdgeOpacity, overlayMiddleOpacity: (Config.app as any)?.ambientCover?.overlayMiddleOpacity } } } /** 二维码生成器只加载一次(ESM-only 包,只能在 SSR 前异步 import) */ let qrcodeReady: Promise | null = null async function ensureQrcodeGenerator (): Promise { if (!qrcodeReady) { qrcodeReady = (async () => { const [qrcode, utils] = await Promise.all([ importEsm('@ikenxuan/qrcode'), Promise.resolve(require('../../../../ktr/utils/QRcode')) ]) if (typeof qrcode.generateSync === 'function') { (utils as any).setQrcodeGenerator(qrcode.generateSync) } })().catch((error: any) => { logger.debug('[Render] 二维码生成器加载失败: ' + String(error?.message ?? error)) }) } return qrcodeReady } /** * 把 React 元素渲染成 HTML 字符串。 * * 用流式 API 而不是同步的 renderToStaticMarkup:部分上游模板会 suspend * (React 19 的 `use()` / 异步资源),同步渲染会直接抛 * "A component suspended while responding to synchronous input" 而整块回退成通用卡片。 * 流式渲染会等挂起的内容就绪(onAllReady)再产出完整 HTML。 */ async function renderToHtml (element: React.ReactElement, timeoutMs = 15000): Promise { return await new Promise((resolve, reject) => { let html = '' const stream = new PassThrough() stream.on('data', (chunk) => { html += chunk.toString() }) stream.on('end', () => resolve(html)) stream.on('error', reject) let settled = false const fail = (error: unknown) => { if (settled) return settled = true reject(error instanceof Error ? error : new Error(String(error))) } const { pipe, abort } = renderToPipeableStream(element, { onError: (error) => logger.debug('[Render] SSR 渲染期错误: ' + String((error as any)?.message ?? error)), onShellError: fail, onAllReady: () => { if (settled) return settled = true pipe(stream) } }) setTimeout(() => { if (settled) return abort() fail(new Error('SSR 渲染超时')) }, timeoutMs).unref?.() }) } /** 用上游模板做 SSR;路由不支持或失败时返回 null */ export async function renderTemplateHtml (route: string, data: any, dark: boolean): Promise { await ensureQrcodeGenerator() let template: any try { template = await loadTemplate(route) } catch (error: any) { logger.debug('[Render] 模板 ' + route + ' 加载失败(可能缺少依赖): ' + String(error?.message ?? error)) return null } if (!template?.component) return null if (typeof template.validate === 'function' && !template.validate(data)) { logger.debug('[Render] 模板 ' + route + ' 的数据校验未通过,回退通用卡片') return null } try { const ctx = buildPosterContext(dark) const element = React.createElement(template.component, { data, ctx }) const body = await renderToHtml(React.createElement('div', { id: 'container' }, element)) const css = loadTemplateCss() const themeClass = dark ? 'dark' : '' /** * 截图专用的覆盖样式: * - 页面不要外边距/内边距,也不要白色背景(否则卡片圆角外面会透出白边、底部会多一条空白); * - 卡片**不要圆角** —— 圆角会让四个角露出页面底色,看起来就是卡片缺了一角。 * 只压掉最外层的圆角,内部小元素(头像、标签)保持原样。 */ const shotCss = 'html,body{margin:0!important;padding:0!important;background:transparent!important;overflow:hidden!important}' + '#container{margin:0!important;padding:0!important;border-radius:0!important}' + '#container>div,#container>div>div{border-radius:0!important}' + 'body{display:inline-block}' return '' + '' + body + '' } catch (error: any) { logger.warn('[Render] 模板 ' + route + ' SSR 失败,回退通用卡片: ' + String(error?.message ?? error)) return null } } /* ------------------------------------------------------------------ * * 截图前的降级(性能) * ------------------------------------------------------------------ */ /** * ## 为什么需要这一段 * * 上游模板每一张卡片都铺了一层**全屏 SVG 噪点**:一个 `absolute inset-0` 的 div, * 里面是 `` + `` + * 一个铺满的 ``。 * * 实测(koishi-plugin-shotkit 内核,bilibili/videoInfo,2880x4036 @2x,同一份 HTML): * * | 变体 | 耗时 | * | --- | --- | * | 原样 | **31.1s** | * | 噪点层换成可平铺小图 | 3.5s | * | 直接去掉噪点层 | 2.8s | * | 去掉噪点层 + 剔除失效 @font-face | 2.0s | * * 也就是**整张卡片九成以上的时间花在这一层噪声上**。原因是内核是**软件光栅化** * (本进程内、没有 GPU、没有分块缓存),feTurbulence 要按设备像素逐个生成噪声: * 1158 万像素的 fractalNoise 就是十几个 G 的浮点运算。浏览器里这层由 Skia 处理并 * 缓存成图层,代价低得多,所以这个坑只在 shotkit 上才这么明显。 * * 而它换来的画面差异小到可以忽略:同一张卡逐像素比对(asis vs 去掉), * **平均差 0.8/255、最大差 4/255、只有 5.4% 的像素差超过 2** —— 肉眼看不出。 * * 处理方式由 `app.noiseOverlay` 决定: * - `off`(默认):整层删掉,最快; * - `tile`:换成一枚 160x160 的噪点小图再平铺(保住颗粒感,仍然比原样快约 9 倍); * - `keep`:保留原样(老行为,很慢,除非你有特别的理由)。 * * ## 只有「整层就是噪点」的 svg 才会被动 * * 判定条件是:这个 `` 里**除 filter/defs/mask 之外只剩下 **,而且带 * feTurbulence。装饰性噪点层正好长这样;万一将来有模板把 feTurbulence 用在别处 * (例如当遮罩喂给可见图形),它就不满足条件,原样保留 —— 宁可慢,也不要把内容删掉。 */ /** 一枚 160x160 的噪点瓦片:同样的 fractalNoise,但只生成 2.5 万像素而不是 1158 万 */ const NOISE_TILE_FILTER = '' + '' + '' + '' + '' + '' + '' /** 平铺版本:把整个噪点 svg 换成「一枚小瓦片 + 平铺」,视觉接近,代价几乎为零 */ const NOISE_TILE_SVG = '' + '' + NOISE_TILE_FILTER + '' + '' + '' + '' + '' /** svg 里除了 filter/defs/mask 之外,是否只剩下 (= 装饰性噪点层) */ function isNoiseOnlySvg (inner: string): boolean { if (!inner.includes('feTurbulence')) return false const visible = inner .replace(//g, '') .replace(//g, '') .replace(//g, '') const tags = visible.match(/<\/?[a-zA-Z][\w:.-]*/g) ?? [] return tags.every((tag) => /^<\/?rect$/.test(tag)) } /** 噪点层降级,返回处理后的 HTML 和被动过的层数(层数只用于日志) */ export function downgradeNoiseLayers (html: string): { html: string; layers: number } { const mode = String((Config.app as any)?.noiseOverlay ?? 'off').toLowerCase() if (mode === 'keep') return { html, layers: 0 } let layers = 0 const output = html.replace(/]*>[\s\S]*?<\/svg>/g, (block, ..._rest: any[]) => { const inner = block.slice(block.indexOf('>') + 1, block.lastIndexOf('')) if (!isNoiseOnlySvg(inner)) return block layers++ if (mode === 'tile') return NOISE_TILE_SVG return '' }) return { html: output, layers } } /** * 剔除「字体文件不存在」的 @font-face。 * * 上游 `style.css` 里有 403 条 @font-face,其中 384 条指向 `./template-fonts/*.woff2` * (HarmonyOS Sans 按 unicode-range 切了 384 个子集)。移植版把 karin 的本地服务地址 * 改写成同目录相对路径,但**这个目录并不存在** —— 于是每次渲染 WebKit 都要为这 384 条 * 声明去读一次不存在的文件,光这一项就占去掉噪点后单张卡片约 1s(2.9s → 2.0s)。 * 另外这些声明本身有 2.4MB,会原样写进每个 HTML 文件。 * * 判定很保守:一条 @font-face 里的 url() 若**全部**指向不存在的本地文件才删; * data:/http(s): 之类的内联或远程字体一律保留(上游就有 19 条内联 data: 字体)。 * * @param baseDir 这段 HTML 最终落地目录,相对 url 以它为基准解析 */ const fontProbeCache = new Map() export function pruneMissingFontFaces (html: string, baseDir: string): string { if (!html.includes('@font-face')) return html return html.replace(/@font-face\s*\{[^}]*\}/g, (block) => { const urls = Array.from(block.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g), (match) => match[2]) if (!urls.length) return block const resolvable = urls.some((url) => { // 内联 / 远程 / 片段引用:不归这里管 if (/^(?:data:|[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(url)) return true const bare = url.split(/[?#]/)[0] if (!bare) return true let file: string try { file = path.resolve(baseDir, decodeURIComponent(bare)) } catch { return true } const cached = fontProbeCache.get(file) if (cached !== undefined) return cached let exists = false try { exists = fs.existsSync(file) } catch { exists = true } fontProbeCache.set(file, exists) return exists }) return resolvable ? block : '' }) } /** 截图前的统一降级:先剔无效字体,再降级噪点层 */ export function optimizeForScreenshot (html: string, baseDir: string): string { const pruned = pruneMissingFontFaces(html, baseDir) const { html: finalHtml, layers } = downgradeNoiseLayers(pruned) if (layers > 0) { logger.debug('[Render] 噪点层降级 ' + layers + ' 处(app.noiseOverlay=' + String((Config.app as any)?.noiseOverlay ?? 'off') + ')') } return finalHtml } /* ------------------------------------------------------------------ * * 截图 * ------------------------------------------------------------------ */ /** * 对一个 HTML 文件截图,返回 base64 和实际产出的 mime。 * * **只用浏览器渲染服务**(koishi-plugin-puppeteer / puppeteer-without-canvas 之类)。 * 3.5.0 起不再支持 shotkit 内核:它的预编译内核在 Windows 上加载不了 https 资源 * (卡片里的封面、头像全是空白框),换来的那点速度不值得让卡片看起来是坏的。 * * scale 会作为 deviceScaleFactor(配置项 app.renderScale,100 = 1x,200 = 2x): * 不设的话高分辨率卡片会被截成 1x,群里看着又小又糊。 */ async function screenshot (htmlPath: string, selector: string, timeout: number, scale = 1, format: 'png' | 'jpeg' = 'jpeg'): Promise<{ base64: string; mime: string; dsf: number }> { const koishiCtx: any = getKoishiContext() /** 浏览器渲染服务(koishi-plugin-puppeteer / puppeteer-without-canvas 之类) */ const puppeteer: any = koishiCtx?.get ? koishiCtx.get('puppeteer') : koishiCtx?.puppeteer if (!puppeteer) { throw new Error('没有可用的渲染器:需要安装浏览器渲染服务(koishi-plugin-puppeteer 或同类插件)才能渲染图片') } // 卡片是给手机看的:**低于 2x 会明显发虚**,所以下限锁 2(上限 3)。 // 想更大更清晰就调 `app.renderScale`(100 → 2x,150 → 3x),配 100 时保持 2x 不出错。 const requested = Number.isFinite(scale) && scale > 0 ? scale * 2 : 2 // 下限是 1x:模板都按 1440 CSS 像素排版,1x 出图正好 1440 宽 —— 也就是切片时会被压到的那个宽度 let deviceScaleFactor = Math.min(3, Math.max(1, requested)) // 安全阀:超大页面降一档缩放,避免单次渲染吃掉几 GB 内存(曾经把实例 OOM 崩掉) try { if (fs.statSync(htmlPath).size > 6 * 1024 * 1024) deviceScaleFactor = Math.max(1, deviceScaleFactor - 1) } catch { /* 忽略 */ } /** * 视口用卡片的**设计宽度**(模板都按 1440 排版),高度按内容量一次。 * * 之前试过「量出内容自然尺寸再把视口撑到那么大」:Chrome 会为此分配超大帧缓冲, * 单次渲染把 node 堆吃到 3GB+ 并直接 OOM 崩掉实例。现在宽度固定、高度只增不减, * 并且不再用 `captureBeyondViewport`(它在大页面上内存表现同样很差)。 */ const DESIGN_WIDTH = 1440 /** 在给定页面上完成「导航 → 量高度 → 截图」,返回 base64 */ const capture = async (page: any): Promise => { if (page.setViewport) { // 宽度就用卡片设计宽度:多给 24px 会多出一条白边(用户看到的「右边超出去一点」) await page.setViewport({ width: DESIGN_WIDTH, height: 900, deviceScaleFactor }) } await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'networkidle0', timeout }) const metrics = await page.evaluate((sel: string) => { const node = document.querySelector(sel) || document.body const rect = node.getBoundingClientRect ? node.getBoundingClientRect() : null return { // 用**元素自己的盒子**而不是整页 scrollHeight:整页量出来的高度会带上 body 边距, // 截图底部就多一条空白 width: Math.ceil(rect?.width || node.scrollWidth || document.body.scrollWidth || 0), height: Math.ceil(rect?.height || node.scrollHeight || document.body.scrollHeight || 0) } }, selector).catch(() => ({ width: 0, height: 0 })) if (page.setViewport) { /** * 宽度按**内容实测**来:正常模板是 1440 宽,而兜底卡片只有 720 —— * 固定 1440 会让兜底卡片右边留一大片白。这里两者都能贴合, * 同时高度按内容撑够,避免长卡片被裁。 */ const width = Math.min(Math.max(metrics.width || DESIGN_WIDTH, 320), 1600) /** * 高度按卡片实际盒子取整(不留容差)。 * 另外把 **页面底色设成卡片自己的底色** —— 视口哪怕只比卡片高一两个像素, * JPEG 里那一条也会是白色页面底,看起来就是「下面有个小白边」。 */ const height = Math.min(Math.max(Math.round(metrics.height), 200), 20000) await page.setViewport({ width, height, deviceScaleFactor }) await page.evaluate((sel: string) => { try { const node = document.querySelector(sel) as HTMLElement | null if (!node) return const card = (node.firstElementChild as HTMLElement) || node const bg = getComputedStyle(card).backgroundColor if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') { document.documentElement.style.background = bg document.body.style.background = bg } } catch { /* 取不到就算了 */ } }, selector).catch(() => undefined) await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))) } /** * 用「卡片元素自己的范围」裁切,而不是整屏截图。 * * 视口宽度比卡片设计宽度多了 24px(给滚动条留余量),整屏截图会在右边多出一条白边, * 用户看到的就是「卡片右边超出了/有白边」。clip 只取 #container 的盒子,正好贴合卡片。 */ const handle = (await page.$(selector)) ?? (await page.$('body')) const clip = handle ? await handle.boundingBox() : null /** * 默认输出 JPEG:2x 的卡片 PNG 动辄十几 MB(带照片背景时尤其夸张), * 超过 QQ 的图片上传限制就会被**当成文件**发出去(用户看到的是「[文件]」而不是图片)。 * 二维码那类需要像素级清晰的场景仍用 PNG(见 Render 里的判断)。 */ const buffer = await page.screenshot({ omitBackground: format === 'png', type: format, quality: format === 'jpeg' ? 92 : undefined } as any) return { base64: buffer.toString('base64'), mime: 'image/' + format, dsf: deviceScaleFactor } } /** * 自己开一个页面截图,**不要走插件的 render() 包装**。 * * 实测:1440x4895 的运行环境诊断卡片,走 `puppeteer.render()` 时仅导航一步 * 就让 node 堆从 206MB 涨到 1407MB(首次渲染累计 +3GB,直接把宿主 OOM 崩掉); * 自己 newPage + goto + 截图只涨几十 MB。所以这里优先直连浏览器,用完关掉页面。 */ const newPage = async (): Promise => { if (!puppeteer) return null try { if (typeof puppeteer.page === 'function') return await puppeteer.page() if (puppeteer.browser && typeof puppeteer.browser.newPage === 'function') return await puppeteer.browser.newPage() } catch (error: any) { logger.debug('[Render] 直连浏览器失败,退回插件 render(): ' + (error?.message ?? error)) } return null } const page = await newPage() if (page) { try { return await capture(page) } finally { try { await page.close() } catch { /* 页面可能已被插件关掉,忽略 */ } } } logger.debug('[Render] 插件未暴露 page/browser,退回 puppeteer.render()') return await puppeteer.render('', async (page: any) => capture(page)) } /** * 读图片真实像素尺寸。 * * 实现已收拢到 `compat/imageSize`(PNG / JPEG / GIF / WebP 四种都认)。 * 以前这里只认 PNG / JPEG —— 同一张 **WebP** 在 `compat/imageMarkdown` 那条链路上读得出来、 * 在这条上读不出来,读不出来的调用方只能「猜一个尺寸」填进 `#宽px #高px`, * 猜出来的框和真实比例对不上 → 用户看到的就是**图片被强制拉伸**。 * 两处共用一份实现之后,这种「一边认得出、一边认不出」的分裂就没有了。 * @param buffer 图片二进制 * @returns 尺寸;认不出的格式返回 `{}`(调用方必须按「尺寸未知」处理,**不要猜**) */ export const getImageMetadata = (buffer: Buffer): ImageMetadata => readImageSize(buffer) /** * 渲染函数(上游签名):\`Render(event, '平台/模板', data)\` → 图片消息段数组。 */ /** * 把模板里的第三方图床代理换回原图地址。 * * 上游模板给头像/装扮这类图套了一层 images.weserv.nl/?url=(本意是绕开 B站的防盗链), * 但那个代理在有些网络下**根本连不上** —— 表现就是卡片里 UP 主头像是个裂图。 * 实测 B站原图直连(i0/i2.hdslb.com)不带 Referer 也是 200,所以这里直接还原成原地址。 */ /** * 把模板里 `src="/image/xxx"` 这种**本地绝对路径**的图片内联成 data URI。 * * 上游是 karin 的 HTTP 服务在提供 `/image/*`(插件 resources/image 目录);Koishi 移植版渲染的是 * 本地 HTML 文件,绝对路径会指向磁盘根目录 → 裂图。评论卡片里的 B站 logo、大会员标记、等级图标 * 就是这么丢的(页脚 logo 之前也踩过同一个坑)。 */ function inlineLocalImages (html: string): string { const imageRoot = path.join(Root.pluginPath, 'resources') const mimeOf = (file: string): string => { const ext = path.extname(file).toLowerCase() if (ext === '.svg') return 'image/svg+xml' if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg' if (ext === '.webp') return 'image/webp' if (ext === '.gif') return 'image/gif' return 'image/png' } return html.replace(/(src=")(\/image\/[^"]+)(")/g, (all: string, head: string, url: string, tail: string) => { try { const relative = decodeURIComponent(url.replace(/^\/+/, '')) const full = path.join(imageRoot, relative) if (!full.startsWith(path.resolve(imageRoot))) return all if (!fs.existsSync(full)) return all const buffer = fs.readFileSync(full) const mime = mimeOf(full) // svg 是文本,用 utf8 base64 也一样能显示,这里统一 base64 省事 return head + 'data:' + mime + ';base64,' + buffer.toString('base64') + tail } catch { return all } }) } function unwrapImageProxy (html: string): string { return html.replace(/https:\/\/images\.weserv\.nl\/\?url=([^"'&\s]+)/gi, (all: string, encoded: string) => { try { const decoded = decodeURIComponent(encoded) return /^https?:\/\//i.test(decoded) ? decoded : all } catch { return all } }) } /** * 「这张卡片该用 1x 还是 2x 渲染」——**不猜,试**。 * * 起因:模板按 1440 CSS 像素排版,长图切片时又会被压回 1440 宽再切 * (见 ImageSlice 的 SLICE_WIDTH),所以长卡片用 2x 渲染出来的 2880 宽像素一个都用不上。 * * 但不能按「高不高」一刀切:同一台机器上实测(都是 1440 宽、同样的 HTML), * 内核在不同倍率下走的**光栅化路径不同**,谁快完全看内容 —— * 评论卡(9489 CSS 高):1x 1.5s vs 2x 3.9s,1x 快 2.6 倍; * B 站评论卡(17965):1x 2.0s vs 2x 7.4s,1x 快 3.7 倍; * 图集卡(2854):1x 7.8s vs 2x 3.0s,**2x 反而快 2.6 倍**; * 直播推荐卡(2593):1x 10.1s vs 2x 4.4s,**2x 快 2.3 倍**。 * 按高度猜必然错一半,所以这里改成**每条路由各测一次**:第一次用配置的倍率, * 第二次换另一档,之后固定用快的那档 —— 代价是每条路由多一次渲染(只在进程活着期间), * 换来的是之后每次都走对路。用户把 renderScale 设成 50(只要 1x)时不试,直接听配置。 */ interface CardScaleTrial { /** 目前认为更快的倍率 */ best: number bestMs: number /** 还没试过的另一档,试完就清空 */ pending?: number } const cardScaleTrials = new Map() /** 配置里的倍率(面板 renderScale:100 = 2x,150 = 3x,50 = 1x) */ function configuredCardScale (): number { return Math.min(2, Math.max(0.25, Number(Config.app?.renderScale ?? 100) / 100)) } /** 这张卡片这次该用多少倍渲染,以及这次是不是「试探」 */ function resolveCardScale (route: string): { scale: number; trial: boolean } { const configured = configuredCardScale() // 用户明确只要 1x:不试,直接听配置 if (configured <= 0.5) return { scale: configured, trial: false } const entry = cardScaleTrials.get(route) if (!entry) return { scale: configured, trial: false } if (entry.pending !== undefined) return { scale: entry.pending, trial: true } return { scale: entry.best, trial: false } } /** 渲染完把这次的耗时喂回来,决定这条路之后固定用哪一档 */ function recordCardScale (route: string, scale: number, ms: number): void { const configured = configuredCardScale() if (configured <= 0.5) return const entry = cardScaleTrials.get(route) if (!entry) { cardScaleTrials.set(route, { best: scale, bestMs: ms, pending: scale > 0.5 ? 0.5 : configured }) return } if (entry.pending !== undefined && Math.abs(scale - entry.pending) < 1e-6) { const better = ms < entry.bestMs const best = better ? scale : entry.best const bestMs = better ? ms : entry.bestMs logger.info('[Render] ' + route + ' 倍率测完了:固定用 ' + best * 2 + 'x(' + Math.round(bestMs) + 'ms,' + '本次 ' + scale * 2 + 'x=' + Math.round(ms) + 'ms / 上次 ' + entry.best * 2 + 'x=' + Math.round(entry.bestMs) + 'ms)') cardScaleTrials.set(route, { best, bestMs }) return } // 稳态:顺手刷新一下耗时,万一机器负载变化明显也能留着对比 entry.bestMs = ms } export const Render = async (_event: any, route: string, data: any): Promise => { const startedAt = Date.now() const dark = await resolveUseDarkTheme(route, data).catch(() => false) const { scale, trial } = resolveCardScale(route) let html = await renderTemplateHtml(route, data, dark) if (!html) { html = buildFallbackHtml(route, data, dark, scale) } html = inlineLocalImages(unwrapImageProxy(html)) const htmlPath = path.resolve(karinPathHtml, Root.pluginName, route.replace(/[\\/]/g, '_') + '.html') try { fs.mkdirSync(path.dirname(htmlPath), { recursive: true }) // 截图前的降级(噪点层 / 失效 @font-face),见本文件「截图前的降级」一节 html = optimizeForScreenshot(html, path.dirname(htmlPath)) fs.writeFileSync(htmlPath, html, 'utf8') // 二维码:必须像素级清晰(JPEG 压缩会影响扫码),继续用 PNG;其它卡片用 JPEG 压体积 const isQrCode = /qrcode/i.test(route) const format: 'png' | 'jpeg' = isQrCode ? 'png' : 'jpeg' // mime 由 screenshot 返回实际产出的格式(就是上面选的那个 format) const htmlBuiltAt = Date.now() const { base64, mime, dsf } = await screenshot(htmlPath, '#container', Number(Config.app?.RenderWaitTime ?? 10) * 1000, scale, format) const shotAt = Date.now() const buffer = Buffer.from(base64, 'base64') const meta = getImageMetadata(buffer) // 把这次的截图耗时喂回去,决定这条路之后固定用哪一档(见上面 cardScaleTrials) recordCardScale(route, scale, shotAt - htmlBuiltAt) logger.info('[Render] ' + route + ' 渲染完成 ' + mime + ' ' + (meta.width ?? '?') + 'x' + (meta.height ?? '?') + ' ' + Math.round(buffer.length / 1024) + 'KB' + ' 用时 ' + (shotAt - startedAt) + 'ms(页面 ' + (htmlBuiltAt - startedAt) + 'ms / 截图 ' + (shotAt - htmlBuiltAt) + 'ms,' + dsf + 'x' + (trial ? ',本次试另一档倍率' : '') + ')') // 用带 mime 的 data URL,适配器才知道按对应格式上传(base64:// 会被当成 PNG) return [segment.image('data:' + mime + ';base64,' + base64)] } catch (error: any) { logger.warn('[Render] ' + route + ' 渲染失败:' + (error?.message ?? error)) return [] } } export default Render