shotkit渲染器 速度快 体积小

koishi-plugin-shotkit

把 HTML、本地文件或 URL 渲染成 PNG / WebP 的 Koishi 渲染服务,内核是 ShotKit(裁切到极限的 WebKit):单进程、无浏览器、无 node-gyp、无安装后下载。

服务名是 shotkit(ctx.shotkit),不是 puppeteer。调用方式和 ctx.puppeteer 一样,名字不一样,所以两者可以同时装、不会抢服务名。需要让写死 ctx.puppeteer 的插件也能用上时,打开配置里的 providePuppeteer(默认关闭,原因见下文)。

页面 JavaScript 永不执行。这不是缺陷而是内核的边界:它换来了 40 MB 左右的常驻内存和 40 毫秒级的单张渲染。

安装

npm install koishi-plugin-shotkit

引擎 @pixel.js/shotkit 是本插件的依赖,会自动装上,并按平台拉取六个平台包中的一个(Windows / Linux / macOS,x64 / arm64)。没有 npm 的时代价是 16 MB 左右的原生包,装完即用,不需要下载浏览器。

在 Koishi 控制台里启用 shotkit 即可。

如果这个环境跑不了 npm(锁死的镜像、坏掉的工具链、离线仓库镜像),可以绕过包管理器直接从 registry 取平台包:

node scripts/vendor-engine.mjs                     # 装到本插件的 node_modules
node scripts/vendor-engine.mjs --registry https://registry.npmmirror.com
node scripts/vendor-engine.mjs 0.4.1               # 指定版本

快速开始

import { Context, h } from 'koishi'

export const inject = ['shotkit']

export function apply(ctx: Context) {
  ctx.command('card').action(async () => {
    // 返回消息元素字符串,和 ctx.puppeteer.render 的返回值完全一致
    return await ctx.shotkit.render('<div style="padding:24px;font-family:微软雅黑">你好,世界</div>')
  })
}

只想拿 Buffer 自己发图:

const { image, stats } = await ctx.shotkit.screenshot({
  html: '<div id="card">卡片</div>',
  selector: '#card',
  type: 'webp',
  quality: 90,
  scale: 2,
})
await ctx.bots[0].sendMessage(channelId, h.image(image, 'image/webp'))
ctx.logger.info('渲染耗时 %d ms,%d 字节', Math.round(stats.timing.render), stats.bytes)

API

ctx.shotkit.render(content, callback?)

与 ctx.puppeteer.render 同形。

形式 行为
render(html) 渲染 HTML,截取 body 元素,返回 <img src="data:image/png;base64,..."/> 字符串
render(html, async (page, next) => ...) 把页面交给回调,回调里调用 next() 截整屏、next(handle) 截元素
// 只截 #card 这个元素
const message = await ctx.shotkit.render(html, async (page, next) => {
  const handle = await page.$('#card')
  return await next(handle)
})

// 整屏
const full = await ctx.shotkit.render(html, async (page, next) => await next())

选择器没命中时不会报错,会退回整屏截图,与 puppeteer 里「page.$ 返回 null 就截整屏」的行为一致。

ctx.shotkit.screenshot(options)

拿到原始结果:{ image: Buffer | null, stats: { bytes, timing: { render, total } } }。传了 path 时 image 为 null。

选项 类型 说明
html string 要渲染的 HTML 字符串。与 file 二选一
file string http(s) URL、file: URL 或本地路径。本地文件由 Node 读取,旁边的相对子资源照常解析
type png / webp / webp-lossless 输出格式,默认 png。没有 jpeg
quality number 1 到 100,仅 webp 有效;png 传了会报错
scale number 设备像素比,0.01 到 8,默认取配置里的 deviceScaleFactor
viewport { width, height } 视口尺寸,默认取配置里的 viewport
selector string 只截第一个命中的元素;与 fullPage 互斥
fullPage boolean 拉到文档高度;与 selector 互斥
omitBackground boolean 保留透明像素,不铺白底
path string 把图片写到该路径,结果里的 image 变成 null
pageGotoParams.timeout number 导航超时毫秒,默认取配置里的 timeout
pageGotoParams.waitUntil load / networkidle 内核总是等网络静默后再画,两者等价;puppeteer 的 networkidle0 / networkidle2 / domcontentloaded 也接受
allowFileAccess boolean 允许页面加载 file: 子资源
baseURL string html 的相对子资源基地址
mimeType string html 或本地文件的 MIME 类型,按扩展名推断;不要带 charset 参数,写了内核就不再按 HTML 解析
userAgent string 本次截图的 UA,覆盖配置里的
deviceScaleFactor number scale 的别名
format string type 的别名。传 jpeg 会抛错
timeout number pageGotoParams.timeout 的别名

别名是为了让 koishi-plugin-kkkshot 那种写法({ selector, timeout, deviceScaleFactor, format, quality })直接可用。

未知选项一律 TypeError,不会被悄悄忽略:写错名字会立刻知道,而不是拿到一张不对的图。

ctx.shotkit.renderFile(file, options?)

返回 Buffer,用于「本地模板文件 + 元素裁切」这条最常见的卡片路径:

const buffer = await ctx.shotkit.renderFile('/path/to/card.html', {
  selector: '#container',
  deviceScaleFactor: 2,
  format: 'png',
  timeout: 15000,
})

ctx.shotkit.page()

返回一个页面对象。ShotKit 一次调用一张图,没有活的 DOM,所以「页面」记录的是要渲染什么:

方法 状态
setContent(html) 可用,记录 HTML
goto(url, options?) 可用,记录 http(s)、file: 或本地路径
setViewport({ width, height, deviceScaleFactor }) 可用,deviceScaleFactor 映射到内核的 scale
setUserAgent(ua) 可用
screenshot(options?) 可用,返回 Buffer;支持 selector / fullPage / type / quality / omitBackground / path
$(selector) 可用,返回带 selector 的句柄,配合 next(handle) 截该元素
close() 可用,标记页面关闭
isClosed() / captures 可用
waitForNetworkIdle(options?) 立即返回:内核本来就会等网络静默
waitForTimeout(ms) 真的等这么多毫秒
evaluate / $eval / $$eval / waitForFunction 抛 NotSupportedError,页面脚本永不执行
$$ 抛 NotSupportedError,只有第一个匹配可寻址
clip 选项 抛 NotSupportedError,改用 selector
boundingBox() 抛 NotSupportedError,内核不暴露元素坐标
content / title / pdf / setCookie / addScriptTag / addStyleTag / click / type / hover / waitForSelector 抛 NotSupportedError

抛错信息里带着替代做法,例如 screenshot({ clip }) 会告诉你去用 screenshot({ selector })。

ctx.shotkit.browser

newPage()、pages()、close()、isConnected()、version()。close() 只关掉它发出去的页面;browser.process() 抛错,因为引擎跑在 Koishi 进程里。

ctx.shotkit.svg(options?)

与 ctx.puppeteer.svg 同一个 API(child / attr / data / line / circle / rect / text / g / fill),渲染方式换成内核截取根 svg 元素:

const element = await ctx.shotkit.svg({ size: 200 })
  .circle(100, 100, 80, { style: 'fill: #3f7fd6' })
  .render(ctx)

生命周期与状态

成员 说明
ctx.shotkit.start() 加载原生插件并开始初始化内核;Koishi 启动时会自动调用
ctx.shotkit.stop() 等在途截图结束并标记停止,下一次截图会自动重新启动
ctx.shotkit.status() { running, enginePath, cacheDir: null, cacheActive: false }
ctx.shotkit.executable 已加载的 shot.node 路径
ctx.shotkit.captures 本进程累计截图次数

配置

字段 默认 说明
viewport.width 1280 默认视口宽度
viewport.height 720 默认视口高度
deviceScaleFactor 1 设备像素比,0.01 到 8;卡片类内容常用 2
userAgent 空 每次截图的 User-Agent,留空用内核默认值
timeout 30000 导航超时毫秒
injectCharset true 给没有声明编码的 HTML 补 <meta charset="utf-8">,详见下文
enginePath 空 自编译内核对 shot.node 的完整路径,等价于 SHOTKIT_NATIVE_PATH
providePuppeteer false 额外注册名为 puppeteer 的服务
htmlComponent false 注册 <html> 消息元素

那个开关:providePuppeteer

打开后本插件会额外注册一个名为 puppeteer 的服务,实现就是 ctx.shotkit 的那个类。它存在的唯一理由是有些插件写死了 ctx.puppeteer,改不了源码。

关于「API 不完整会不会有兼容性问题」,这里的设计是:

  • 兼容服务与 ctx.shotkit 是同一个类、同一套选项处理,不存在「兼容版少一截 API」的情况。凡是 ctx.shotkit 做不到的(页面脚本、clip、jpeg、$$ 等等),在 puppeteer 名下同样做不到,而且抛的是同一条带替代方案的错误,不会静默给出另一张图。
  • 所以风险不在「API 不完整」,而在内核边界:原本依赖 page.evaluate 量高度、或依赖 clip 裁切的代码,在 shotkit 上会明确报错。这类代码通常是 puppeteer 的「自己开页面量尺寸」路径,见下文的排查表。
  • 默认关闭是因为它会和 koishi-plugin-puppeteer 抢同一个服务名。两者同时启用时后加载的生效,另一个静默失效,这不是受支持的组合,请只开一个。

htmlComponent 同理默认关闭:koishi-plugin-puppeteer 也注册 <html> 元素。打开后的用法一致:

h('html', { style: { padding: '12px' } }, [h('div', '内容')])
h('html', { src: 'https://example.com' })

从 ctx.puppeteer 迁移过来

puppeteer 写法 在 shotkit 上
await ctx.puppeteer.render(html) await ctx.shotkit.render(html),一样
await ctx.puppeteer.render(html, cb) 一样,next(handle) 走内核的元素截取
page.setContent / page.goto / page.setViewport / page.screenshot 一样
page.screenshot({ clip }) 改成 page.screenshot({ selector })
page.screenshot({ type: 'jpeg' }) 改成 webp(配 quality)或 png
const clip = await (await page.$(sel)).boundingBox() 删掉,直接 page.screenshot({ selector: sel })
await page.evaluate(...) 量高度、改样式 无法照搬:内核不执行脚本。高度交给 viewport 与 fullPage,底色直接写进模板
page.waitForNetworkIdle() 保留即可,立即返回
args / executablePath / headless 没有浏览器,这些配置不存在

在 koishi-plugin-kkk 里使用

kkk 的渲染入口(src/karin/module/utils/Render/index.ts)优先使用本服务:装了 koishi-plugin-shotkit 就走内核,没装才退回 ctx.puppeteer。调用形状与原来的 kkkshot 快路径一致:

const buffer = await ctx.shotkit.renderFile(htmlPath, {
  selector: '#container',
  timeout,
  deviceScaleFactor,
  type: 'png',
})

配置上启用 shotkit 插件即可,providePuppeteer 不需要打开(kkk 走的是服务名 shotkit,不是 puppeteer)。

渲染耗时受模板影响极大。同一张 B 站视频卡片实测:

模板情况 内核耗时
原样(内联 3MB 未 purge 的 Tailwind v4 CSS) 5.5 到 10 秒
去掉含 color-mix() / oklab 的规则 1.4 秒
去掉整段 <style> 0.67 秒

也就是说,卡片模板把 CSS 收干净带来的收益,比换渲染器大得多。

中文、字体与编码

这两条是实测踩出来的,卡片渲染成乱码或字体不对时先看这里。

编码。 内核拿到的是一串字节加一个 MIME 类型。MIME 是纯 text/html 时,解析器会退回单字节旧编码,于是「中文」渲染成 䏿–‡。所以:

  • html 选项:injectCharset 打开时(默认)插件会自动补 <meta charset="utf-8">,放在 <head> 里、<html> 后面或片段开头,位置都是解析器认得的。
  • file 选项:模板文件自己要写 <meta charset="utf-8">,插件不会去改你的文件。
  • mimeType 不要写 text/html; charset=utf-8:内核把整串当类型用,带上参数就不再按 HTML 解析,选择器会直接命中不到元素。

字体。 实测(Windows x64,@pixel.js/shotkit 0.4.1):sans-serif、serif、monospace、system-ui 这几个泛型族全部落到同一个默认字体(看起来是衬线体),写具体字体名才有效。可用的例如 "Microsoft YaHei" / 微软雅黑、Arial / Helvetica、Segoe UI、SimSun / 宋体。所以卡片模板里请写具体字体名,不要指望 sans-serif:

body { font-family: "Microsoft YaHei", sans-serif; }

性能

本机实测(Windows x64,@pixel.js/shotkit 0.4.1,520x180 的卡片,scale: 2 输出 1136x228 PNG):

指标 数值
首次渲染(含内核初始化) 354 ms
之后单张 39 到 42 ms
8 张并发提交 合计 329 ms(内核单线程 FIFO,串行执行)
进程 RSS 10 张之后约 99 MB(引擎自身约 22 MB)

内核在一个进程里只有一条渲染线程和一个 FIFO 队列:并发提交不会更快,只是排队,Node 主事件循环不会被阻塞。原生崩溃会带走整个 Koishi 进程,这是进程内绑定的固有权衡;需要故障隔离就得用子进程或 CLI 的 --serve。

排错

现象 原因与处理
启动日志出现 the shotkit service needs the native engine package @pixel.js/shotkit 没装上,或当前平台没有预编译包。按日志里的命令安装,或用 enginePath 指向自编译的 shot.node
中文渲染成 䏿–‡ HTML 没有编码声明且 injectCharset 被关了;本地模板文件请自己写 <meta charset="utf-8">
selector matched no element 选择器没命中。ctx.shotkit.render 会自动退回整屏,直接调 screenshot 时会抛出来
卡片右下留白或底部多一条白边 用 selector 截元素盒子,别用 fullPage;需要底色时把底色写进模板,或 omitBackground: false
is not available on the shotkit service 用到了内核做不到的 API,错误信息里有替代写法,见上文迁移表
同时装了 koishi-plugin-puppeteer 又开了 providePuppeteer 服务名冲突,关掉其中一个

开发

node scripts/build.mjs           # src/ 编译到 lib/
node scripts/build.mjs --check   # 只做类型检查
node scripts/smoke.mjs           # 起一个真 Koishi 上下文跑完整接口,图片输出到 .smoke/
node scripts/vendor-engine.mjs   # 不用包管理器,直接从 registry 取原生引擎
node scripts/link.mjs            # 把本目录链接进 app 的 node_modules(koishi.yml 按包名加载时需要)

本地开发时,Koishi 是按包名 koishi-plugin-shotkit 去 app 的 node_modules 里找插件的。
yarn install(workspaces 里有 plugins/*)会建好这条链接;跑不了包管理器时用 node scripts/link.mjs,
它建的是目录链接而不是副本,改完代码重新 build 即可生效。

许可

MIT。引擎 ShotKit 由其仓库的许可条款约束。

2 个赞

渲染对比 · 4 个模板 · 每个引擎 3 轮 · 串行执行
进程 RSS 基线 322MB,单次超时 60.00s

[1/4] text.html
shotkit 内核 冷 154ms 热(中位) 118ms 1440x472 102KB 本进程 +26.2MB 内核所在进程 344MB
Chrome 冷 1.54s 热(中位) 1.46s 1440x472 314KB 本进程 +2.5MB 浏览器进程 323MB 页面 JS 堆 1.3MB

[2/4] chinese.html
shotkit 内核 冷 185ms 热(中位) 149ms 1440x1032 107KB 本进程 +12.4MB 内核所在进程 183MB
Chrome 冷 1.41s 热(中位) 1.42s 1440x1030 160KB 本进程 +2.2MB 浏览器进程 324MB 页面 JS 堆 1.3MB

[3/4] remote.html
shotkit 内核 冷 541ms 热(中位) 148ms 1440x608 329KB 本进程 +12.5MB 内核所在进程 192MB
Chrome 冷 2.40s 热(中位) 1.36s 1440x608 316KB 本进程 +1.7MB 浏览器进程 315MB 页面 JS 堆 1.3MB

[4/4] heavy.html
shotkit 内核 冷 1.61s 热(中位) 1.56s 1440x9094 688KB 本进程 +111MB 内核所在进程 208MB
Chrome 冷 2.33s 热(中位) 2.31s 1440x9094 676KB 本进程 +5.0MB 浏览器进程 333MB 页面 JS 堆 1.3MB

1 个赞

koishi-plugin-render-bench

把同一批模板分别交给 Chrome(ctx.puppeteer)和 shotkit 内核(ctx.shotkit)渲染,串行执行,然后把耗时和内存占用发给你。

一条指令:

渲染对比

它想回答的问题

同一张卡片,两个引擎到底差多少,以及差在哪里。之前的排查里已经冒出三个具体问题,这份对比就是为了把它们量出来:

  1. 远程资源:预编译内核在 Windows 上拉不到 https 资源,而卡片里的封面、头像、图标几乎全是 https。remote.html 里有两张 https、一张 http 做对照,跑完看图就知道。
  2. 大样式表:内核处理 color-mix() / oklch() 这类现代颜色函数明显偏慢。heavy.html 塞了 1000 多条含这些函数的规则,用来看差距有多大。
  3. 内存口径:内核跑在 Koishi 进程里,Chrome 把渲染放在自己的进程里 —— 两个数字不能直接对比,所以报告按不同口径分列。

前置条件

两个服务都要装、都要开,缺哪个那一侧就报「跳过」,另一侧照常跑:

服务 由谁提供
ctx.puppeteer koishi-plugin-puppeteer 或 @shangxueink/koishi-plugin-puppeteer-without-canvas
ctx.shotkit koishi-plugin-shotkit

Chrome 那一侧直接复用你已经装好的 ctx.puppeteer,不会另外起一个浏览器。

用法

命令 说明
渲染对比 测全部模板
渲染对比 remote 只测文件名里含 remote 的模板
渲染对比 heavy -r 5 测 5 轮
渲染对比 remote -i 把两边渲染出来的图片一起发出来
渲染对比 -o shotkit-first 先内核、后 Chrome(默认相反)

选项:

选项 说明
-r <n> 每个引擎每个模板渲染几轮。第 1 轮算冷启动,其余取中位数。
-i, --image 附带图片(默认就是开的,用配置 sendImages: false 关掉)。
-o <order> puppeteer-first 或 shotkit-first。

消息是边跑边发的

一轮对比可能要跑好几分钟,所以不会憋到最后才发一条:

  1. 立刻回一条开始提示(模板数、轮数、引擎顺序、RSS 基线),你看到它就说明指令跑起来了;
  2. 每个模板跑完发一条:两边的数字 + 两边的渲染结果图;
  3. 最后一条是合计与内存小结。

同时每一步都会写日志到 Koishi 控制台(render-bench),聊天里收不到、日志里有。如果连第 1 条开始提示都没出现,那是插件没加载,不是卡住 —— 重启 Koishi 或去控制台看插件列表。

先想快速验证链路通不通,用 渲染对比 text 只跑一个最轻的模板。

报告怎么读

渲染对比 · 4 个模板 · 每个引擎 3 轮 · 串行执行
进程 RSS 基线 77.7MB,单次超时 60.00s

[1/4] text.html
  Chrome          冷 1.42s   热(中位) 0.21s   1448x472 96KB   本进程 +5.2MB   浏览器进程 318MB   页面 JS 堆 12.4MB
  shotkit 内核    冷 519ms   热(中位) 110ms   1440x472 102KB  本进程 +28.5MB   内核所在进程 102MB

热渲染中位数合计:Chrome 0.21s,shotkit 内核 0.11s
进程 RSS:基线 77.7MB → 结束后 117MB
列 含义
冷 该引擎在该模板上的第一次渲染耗时,含引擎预热(内核是 WebKit 初始化,Chrome 是首屏导航)
热(中位) 之后几轮的中位数。只有一轮时等于冷启动那一轮
尺寸 / 体积 产出 PNG 的宽高与字节数。两边尺寸不一致通常意味着元素盒子或缩放不同,值得看图
本进程 渲染期间 Koishi 进程 RSS 的峰值增量,按 20ms 采样
浏览器进程 Chrome 进程树的工作集合计(含子进程),用 PowerShell / ps 查;查不到这一列不显示
页面 JS 堆 puppeteer 的 page.metrics().JSHeapUsedSize
内核所在进程 内核在本进程里,这列就是这个进程的绝对 RSS,可以理解成「内核常驻成本的上界」

两边的内存不能直接相减。 Chrome 把渲染放在独立进程,本进程几乎不涨,真实占用在「浏览器进程」那列;内核在本进程里,本进程涨多少就是它涨多少,而且这部分在第一次渲染后基本会留着不还。所以看结论时:Chrome 关注「浏览器进程」的绝对值,内核关注「本进程」的增量和结束后的 RSS。

串行保证

这条是硬性要求,代码里做了三层:

  1. 同一时刻只跑一个渲染:外层循环是「模板 → 引擎 → 轮次」,一层层 await,没有任何 Promise.all。
  2. 模板之间、引擎之间、轮次之间都留 gapMs(默认 400ms)的间隔,让上一轮彻底安静下来。
  3. 模块级互斥锁:有人正在跑的时候,第二次触发直接回「上一个对比还在跑」,不会插进来。

理由很实际:两个引擎同时跑会互相抢 CPU,测出来的时间谁都不准;而且一个在独立进程、一个在本进程,混在一起采样内存也会互相污染。

模板

模板放在 templates/,随便加、随便改,命令里用文件名的一部分就能单独测。

文件 测什么
text.html 纯文本 + 渐变 + flex 排版,不引用任何外部资源。这是「公平基线」:两边结果应该几乎一样,差距只体现在速度和内存上
chinese.html 中文排版与字体回退:黑体、宋体、等宽三种字体名混排,看两边的换行位置、标点、行高是否一致
remote.html 远程图片:两张 https、一张 http 做对照、一张远程图标。四个格子里有图 = 该引擎能取远程资源
heavy.html 1000 多条含 color-mix() / oklch() 的规则 + 180 个元素,用来放大样式解析与合成开销

每个模板的根元素都是 #container,两个引擎都按这个选择器截元素盒子、都用 2x 缩放,保证可比。

配置

字段 默认 说明
templatesDir 空 模板目录,留空用插件自带的 templates/
templates [] 默认测哪些模板,留空 = 目录下全部
rounds 3 每个引擎每个模板渲染几轮(第 1 轮算冷启动)
sendImages true 默认是否附图,等价于常驻 -i
maxImageBytes 4194304 单张图片超过这个字节数就不发图,只在报告里写尺寸
order puppeteer-first 引擎顺序
gapMs 400 渲染之间的间隔(毫秒)
timeout 60000 单次渲染超时(毫秒)
sampleIntervalMs 20 内存采样间隔(毫秒)

已知限制

  • 浏览器进程内存依赖查询系统进程树:Windows 用 powershell.exe + Get-CimInstance,其它平台用 ps。查不到(受限环境、权限不足)时那一列不显示,不影响其它数字。
  • 单次采样拿不到真正的峰值内存,只能给到 20ms 粒度下的峰值;大图渲染如果瞬时冲高又回落,可能低估。
  • 本轮第一次渲染会带上引擎预热成本,所以「冷」那一列天然偏高,这是刻意的:真实场景里第一张卡就是这个代价。
  • 两个引擎都渲染本地 HTML 文件。远程 URL 或 inline HTML 不在覆盖范围内。

许可

MIT。

3 个赞
3 个赞

不如puppeteer

而且也还有 .node 文件,并且在termux环境下没有浏览器好支持


2 个赞