将渲染逻辑独立,支持扩展渲染器

This commit is contained in:
Kokomi 2023-04-18 02:37:38 +08:00
parent 9e20c1ccc9
commit 4efd53f59b
9 changed files with 187 additions and 210 deletions

View File

@ -1,3 +1,9 @@
# 3.0.2
* 3.6卡池以及图像武器别名等数据更新 **@cvs**
* 将渲染逻辑独立,支持扩展渲染器 **@ikuaki**
* icqq等依赖版本升级
# 3.0.1
* 添加`#版本`命令,用于查看更新记录

View File

@ -1,60 +1,28 @@
import fs from 'node:fs'
import path from 'path'
import { fileURLToPath } from 'url'
import cfg from '../config/config.js'
import Renderer from '../renderer/Renderer.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const rendererDir = path.join(__dirname, "../../renderers/");
let rendererBackends = {};
const rendererProxyHandler = {
get(target, prop, receiver) {
if (!(prop in receiver)) {
logger.fatal("在类 " + target.constructor.name + " 上访问了未实现的方法或属性 " + prop + ", 请报告错误!");
return undefined;
}
return Reflect.get(...arguments);
/**
* 暂时保留对手工引用puppeteer.js的兼容
* 后期会逐步废弃
* 只提供截图及分片截图功能
*/
export default {
// 截图
async screenshot (name, data = {}) {
let renderer = Renderer.getRenderer()
let img = await renderer.render(name, data)
return img ? segment.image(img) : img
},
}
async function registerRendererBackends() {
const subFolders = fs.readdirSync(rendererDir, { withFileTypes: true }).filter((dirent) => dirent.isDirectory());
for (let subFolder of subFolders) {
const newRendererBackends = (await import(path.join(rendererDir, subFolder.name, 'index.js'))).default;
for (let rendererBackendName in newRendererBackends) {
rendererBackends[rendererBackendName] = newRendererBackends[rendererBackendName];
logger.mark("[渲染后端加载]: 导入 " + rendererBackendName);
// 分片截图
async screenshots (name, data = {}) {
let renderer = Renderer.getRenderer()
data.multiPage = true
let imgs = await renderer.render(name, data) || []
let ret = []
for (let img of imgs) {
ret.push(img ? segment.image(img) : img)
}
return ret.length > 0 ? ret : false
}
}
function selectRendererBackend() {
let rendererBackendName = cfg.renderer?.name;
// 未指定,回退到 puppeteer
if (!rendererBackendName) rendererBackendName = "puppeteer";
let rendererBackendConstructor = rendererBackends[rendererBackendName];
if (!rendererBackendConstructor) {
logger.warn("未知的渲染后端 " + rendererBackendName + ", 回退到 puppeteer");
rendererBackendName = "puppeteer";
rendererBackendConstructor = rendererBackends[rendererBackendName];
}
if (!rendererBackendConstructor.isImportable()) {
logger.warn("渲染后端 " + rendererBackendName + " 不可用 (导入失败), 回退到 puppeteer");
rendererBackendName = "puppeteer";
rendererBackendConstructor = rendererBackends[rendererBackendName];
}
logger.mark("当前渲染后端:" + rendererBackendName);
return rendererBackendConstructor;
}
async function newRenderer() {
// 自动扫描,并注册渲染后端
await registerRendererBackends();
// 选择渲染后端
let rendererBackendConstructor = selectRendererBackend();
return new rendererBackendConstructor();
}
export default new Proxy(await newRenderer(), rendererProxyHandler);

40
lib/renderer/Renderer.js Normal file
View File

@ -0,0 +1,40 @@
import fs from 'node:fs'
import yaml from 'yaml'
import lodash from 'lodash'
import cfg from '../config/config.js'
import { Data } from '#miao'
let rendererBackends = {}
async function registerRendererBackends () {
const subFolders = fs.readdirSync(`${process.cwd()}/renderers`, { withFileTypes: true }).filter((dirent) => dirent.isDirectory())
for (let subFolder of subFolders) {
let name = subFolder.name
const rendererFn = await Data.importDefault(`/renderers/${name}/index.js`)
let configFile = `./renderers/${name}/config.yaml`
let rendererCfg = {}
if (fs.existsSync(configFile)) {
try {
rendererCfg = yaml.parse(fs.readFileSync(configFile, 'utf8'))
} catch (e) {
rendererCfg = {}
}
}
let renderer = rendererFn(rendererCfg)
if (!renderer.id || !renderer.type || !renderer.render || !lodash.isFunction(renderer.render)) {
logger.warn('渲染后端 ' + (renderer.id || subFolder.name) + ' 不可用')
}
rendererBackends[renderer.id] = renderer
logger.mark('[渲染后端加载]: 导入 ' + renderer.id)
}
}
await registerRendererBackends()
export default {
getRenderer () {
// TODO 渲染器降级
return rendererBackends[cfg.renderer?.name || 'puppeteer']
}
}

View File

@ -1,6 +1,6 @@
{
"name": "miao-yunzai",
"version": "3.0.1",
"version": "3.0.2",
"author": "Yoimiya-Kokomi, Le-niao",
"description": "QQ group Bot",
"main": "app.js",

View File

@ -1,6 +1,6 @@
*
!.gitignore
!base_renderer.js
!puppeteer
!puppeteer/**
puppeteer/config.yaml

View File

@ -1,28 +0,0 @@
export default class BaseRenderer {
/**
* 截图
*/
async screenshot(name, data = {}) {
this.constructor._WARNING('screenshot(name, data)');
return false;
}
/**
* 分片截图
*/
async screenshots(name, data = {}) {
this.constructor._WARNING('screenshots(name, data)');
return false;
}
/**
* 该渲染后端是否可导入
*/
static isImportable() {
return false;
}
static _WARNING(fName) {
logger.fatal('方法 "' + fName + ' 在类 ' + this.name + ' 中未被实现,请报告错误!');
}
}

View File

@ -0,0 +1,15 @@
# 如需自定义,复制此文件为 config.yaml 进行配置
# 更新配置后需要重启
# chromium 地址
chromiumPath:
# headless
headless: true
# puppeteer启动args
args:
- --disable-gpu
- --disable-setuid-sandbox
- --no-sandbox
- --no-zygote

View File

@ -1,7 +1,22 @@
import PuppeteerRenderer from './puppeteer_renderer.js';
import Puppeteer from './lib/puppeteer.js'
const newRendererBackends = {
'puppeteer': PuppeteerRenderer
};
/**
*
* @param config 本地config.yaml的配置内容
* @returns renderer 渲染器对象
* @returns renderer.id 渲染器ID对应renderer中选择的id
* @returns renderer.type 渲染类型保留字段暂时支持image
* @returns renderer.render 渲染入口
*/
export default function (config) {
// TODO Puppeteer待简化重构
const PuppeteerRender = new Puppeteer(config)
export default newRendererBackends;
return {
id: 'puppeteer',
type: 'image',
async render (name, data) {
return await PuppeteerRender.screenshot(name, data)
}
}
}

View File

@ -3,9 +3,9 @@ import os from 'node:os'
import lodash from 'lodash'
import template from 'art-template'
import chokidar from 'chokidar'
// 暂时保留对原config的兼容
import cfg from '../../lib/config/config.js'
import common from '../../lib/common/common.js'
import BaseRenderer from '../base_renderer.js'
import { Data } from '#miao'
const _path = process.cwd()
@ -14,9 +14,8 @@ let puppeteer = {}
// mac地址
let mac = ''
export default class PuppeteerRenderer extends BaseRenderer {
constructor() {
super()
export default class PuppeteerRenderer {
constructor (config) {
this.browser = false
this.lock = false
this.shoting = []
@ -25,18 +24,17 @@ export default class PuppeteerRenderer extends BaseRenderer {
/** 截图次数 */
this.renderNum = 0
this.config = {
headless: true,
args: [
headless: Data.def(config.headless, true),
args: Data.def(config.args, [
'--disable-gpu',
'--disable-setuid-sandbox',
'--no-sandbox',
'--no-zygote'
]
])
}
if (cfg.bot?.chromium_path) {
if (cfg?.bot?.chromium_path || config.chromiumPath) {
/** chromium其他路径 */
this.config.executablePath = cfg.bot.chromium_path
this.config.executablePath = cfg.bot?.chromium_path || config.chromiumPath
}
this.html = {}
@ -46,9 +44,7 @@ export default class PuppeteerRenderer extends BaseRenderer {
async initPupp () {
if (!lodash.isEmpty(puppeteer)) return puppeteer
puppeteer = (await import('puppeteer')).default
return puppeteer
}
@ -185,12 +181,16 @@ export default class PuppeteerRenderer extends BaseRenderer {
* @param data.quality screenshot参数图片质量 0-100jpeg是可传默认90
* @param data.omitBackground screenshot参数隐藏默认的白色背景背景透明默认不透明
* @param data.path screenshot参数截图保存路径截图图片类型将从文件扩展名推断出来如果是相对路径则从当前路径解析如果没有指定路径图片将不会保存到硬盘
* @return icqq img
* @param data.multiPage 是否分页截图默认false
* @param data.multiPageHeight 分页状态下页面高度默认4000
* @param data.pageGotoParams 页面goto时的参数
* @return img/[]img 不做segment包裹
*/
async screenshot (name, data = {}) {
if (!await this.browserInit()) {
return false
}
const pageHeight = data.multiPageHeight || 4000
let savePath = this.dealTpl(name, data)
if (!savePath) return false
@ -198,127 +198,80 @@ export default class PuppeteerRenderer extends BaseRenderer {
let buff = ''
let start = Date.now()
let ret = []
this.shoting.push(name)
try {
const page = await this.browser.newPage()
await page.goto(`file://${_path}${lodash.trim(savePath, '.')}`, data.pageGotoParams || {})
let pageGotoParams = lodash.extend({ timeout: 120000 }, data.pageGotoParams || {})
await page.goto(`file://${_path}${lodash.trim(savePath, '.')}`, pageGotoParams)
let body = await page.$('#container') || await page.$('body')
// 计算页面高度
const boundingBox = await body.boundingBox()
// 分页数
let num = 1
let randData = {
// encoding: 'base64',
type: data.imgType || 'jpeg',
omitBackground: data.omitBackground || false,
quality: data.quality || 90,
path: data.path || ''
}
if (data.imgType === 'png') delete randData.quality
if (data.multiPage) {
randData.type = 'jpeg'
num = Math.round(boundingBox.height / pageHeight) || 1
}
if (data.imgType === 'png') {
delete randData.quality
}
if (!data.multiPage) {
buff = await body.screenshot(randData)
page.close().catch((err) => logger.error(err))
} catch (error) {
logger.error(`图片生成失败:${name}:${error}`)
/** 关闭浏览器 */
if (this.browser) {
await this.browser.close().catch((err) => logger.error(err))
}
this.browser = false
buff = ''
return false
}
this.shoting.pop()
if (!buff) {
logger.error(`图片生成为空:${name}`)
return false
}
this.renderNum++
/** 计算图片大小 */
let kb = (buff.length / 1024).toFixed(2) + 'kb'
const kb = (buff.length / 1024).toFixed(2) + 'kb'
logger.mark(`[图片生成][${name}][${this.renderNum}次] ${kb} ${logger.green(`${Date.now() - start}ms`)}`)
this.restart()
return segment.image(buff)
}
/**
* `chromium` 分片截图
*/
async screenshots(name, data = {}) {
// FIXME: pageHeight 作为参数?
const pageHeight = 7000
await this.browserInit()
if (!this.browser) return false
const savePath = this.dealTpl(name, data)
if (!savePath) return false
const page = await this.browser.newPage()
try {
await page.goto(`file://${_path}${lodash.trim(savePath, '.')}`, { timeout: 120000 })
const body = await page.$('#container') || await page.$('body')
const boundingBox = await body.boundingBox()
const num = Math.round(boundingBox.height / pageHeight) || 1
this.renderNum++
ret.push(buff)
} else {
// 分片截图
if (num > 1) {
await page.setViewport({
width: boundingBox.width,
height: pageHeight + 100
})
}
const img = []
for (let i = 1; i <= num; i++) {
const randData = {
type: 'jpeg',
quality: 90
}
if (i !== 1 && i === num) {
await page.setViewport({
width: boundingBox.width,
height: parseInt(boundingBox.height) - pageHeight * (num - 1)
})
}
if (i !== 1 && i <= num) {
await page.evaluate(() => window.scrollBy(0, 7000))
}
let buff
if (num === 1) {
buff = await body.screenshot(randData)
} else {
buff = await page.screenshot(randData)
}
if (num > 2) await common.sleep(200)
if (num > 2) await Data.sleep(200)
this.renderNum++
/** 计算图片大小 */
const kb = (buff.length / 1024).toFixed(2) + 'kb'
logger.mark(`[图片生成][${name}][${this.renderNum}次] ${kb}`)
img.push(segment.image(buff))
logger.mark(`[图片生成][${name}][${i}/${num}] ${kb}`)
ret.push(buff)
}
await page.close().catch((err) => logger.error(err))
if (num > 1) {
logger.mark(`[图片生成][${name}] 处理完成`)
}
return img
}
page.close().catch((err) => logger.error(err))
} catch (error) {
logger.error(`图片生成失败:${name}:${error}`)
/** 关闭浏览器 */
@ -326,7 +279,20 @@ export default class PuppeteerRenderer extends BaseRenderer {
await this.browser.close().catch((err) => logger.error(err))
}
this.browser = false
ret = []
return false
}
this.shoting.pop()
if (ret.length === 0 || !ret[0]) {
logger.error(`图片生成为空:${name}`)
return false
}
this.restart()
return data.multiPage ? ret : ret[0]
}
/** 模板 */
@ -389,10 +355,5 @@ export default class PuppeteerRenderer extends BaseRenderer {
}
}
}
static isImportable() {
// XXX: Puppeteer 为默认的,因此总可以被导入
return true;
}
}