UNPKG

web-video-creator

Version:

A framework for creating videos based on Node.js + Puppeteer + FFmpeg.

466 lines (439 loc) 17.5 kB
import assert from "assert"; import AsyncLock from "async-lock"; import puppeteer, { Browser as _Browser } from "puppeteer-core"; import genericPool, { Pool as _Pool } from "generic-pool"; import _ from "lodash"; import Pool from "./ResourcePool.js"; import Page from "./Page.js"; import globalConfig from "../lib/global-config.js"; import installBrowser from "../lib/install-browser.js"; import logger from "../lib/logger.js"; import util from "../lib/util.js"; // 浏览器计数 let browserIndex = 1; /** * 浏览器 */ export default class Browser { /** 浏览器状态枚举 */ static STATE = { /** 未初始化 */ UNINITIALIZED: Symbol("UNINITIALIZED"), /** 已就绪 */ READY: Symbol("READY"), /** 不可用 */ UNAVAILABLED: Symbol("UNAVAILABLED"), /** 已关闭 */ CLOSED: Symbol("CLOSED") }; /** * @typedef {Object} PageOptions * @property {number} [width] - 页面视窗宽度 * @property {number} [height] - 页面视窗高度 * @property {string} [userAgent] - 用户UA * @property {number} [beginFrameTimeout=5000] - BeginFrame超时时间(毫秒) * @property {string} [frameFormat="jpeg"] - 帧图格式 * @property {number} [frameQuality=80] - 帧图质量(0-100) */ /** @type {string} - 浏览器ID */ id = `Browser@${browserIndex++}`; /** @type {Browser.STATE} - 浏览器状态 */ state = Browser.STATE.UNINITIALIZED; /** @type {Pool} - 浏览器池 */ parent = null; /** @type {_Pool} - 浏览器页面资源池 */ #pagePool; /** @type {_Browser} - 浏览器实例 */ target = null; /** @type {number} - 页面资源最大数量 */ numPageMax; /** @type {number} - 页面资源最小数量 */ numPageMin; /** @type {string} - 浏览器入口文件路径 */ executablePath; /** @type {boolean=true} - 是否使用GPU加速渲染 */ useGPU; /** @type {boolean=true} - 3D渲染后端是否使用Angle,建议开启 */ useAngle; /** @type {boolean=false} - 是否禁用共享内存,当/dev/shm较小时建议开启此选项 */ disableDevShm; /** @type {string[]} - 浏览器启动参数 */ args = []; /** @type {PageOptions} - 浏览器日志是否输出到控制台 */ pageOptions = {}; /** @type {boolean} - 浏览器是否已关闭 */ closed = false; /** @type {Function[]} - 启动回调队列 */ #launchCallbacks = []; /** @type {boolean} - 是否初始页面 */ #firstPage = true; /** @type {AsyncLock} - 异步锁 */ #asyncLock = new AsyncLock(); /** * 构造函数 * * @param {Pool} parent - 浏览器资源池 * @param {Object} options - 浏览器选项 * @param {number} [options.numPageMax=5] - 页面资源最大数量 * @param {number} [options.numPageMin=1] - 页面资源最小数量 * @param {string} [options.executablePath] - 浏览器入口文件路径 * @param {boolean} [options.useGPU=true] - 是否使用GPU加速渲染,建议开启 * @param {boolean} [options.useAngle=true] - 渲染后端是否使用Angle,建议开启 * @param {boolean} [options.disableDevShm=false] - 是否禁用共享内存,当/dev/shm较小时建议开启此选项 * @param {string[]} [options.args] - 浏览器启动参数 * @param {PageOptions} [options.pageOptions] - 页面选项 */ constructor(parent, options = {}) { assert(parent instanceof Pool, "Browser parent must be Pool"); this.parent = parent; assert(_.isObject(options), "Browser options must be object"); const { numPageMax, numPageMin, executablePath, useGPU, useAngle, disableDevShm, args, pageOptions } = options; assert(_.isUndefined(numPageMax) || _.isFinite(numPageMax), "Browser numPageMax must be number"); assert(_.isUndefined(numPageMin) || _.isFinite(numPageMin), "Browser numPageMin must be number"); assert(_.isUndefined(executablePath) || _.isBoolean(executablePath), "Browser executablePath must be string"); assert(_.isUndefined(useGPU) || _.isBoolean(useGPU), "Browser useGPU must be boolean"); assert(_.isUndefined(useAngle) || _.isBoolean(useAngle), "Browser useAngle must be boolean"); assert(_.isUndefined(disableDevShm) || _.isBoolean(disableDevShm), "Browser disableDevShm must be boolean"); assert(_.isUndefined(args) || _.isArray(args), "Browser args must be array"); assert(_.isUndefined(pageOptions) || _.isObject(pageOptions), "Browser pageOptions must be object"); this.numPageMax = _.defaultTo(numPageMax, _.defaultTo(globalConfig.numPageMax, 5)); this.numPageMin = _.defaultTo(numPageMin, _.defaultTo(globalConfig.numPageMin, 1)); this.executablePath = _.defaultTo(executablePath, _.defaultTo(globalConfig.browserExecutablePath, null)); this.useGPU = _.defaultTo(useGPU, _.defaultTo(globalConfig.browserUseGPU, true)); this.useAngle = _.defaultTo(useAngle, _.defaultTo(globalConfig.browserUseAngle, true)); this.disableDevShm = _.defaultTo(disableDevShm, _.defaultTo(globalConfig.browserDisableDevShm, false)); this.args = _.defaultTo(args, []); this.pageOptions = _.defaultTo(pageOptions, {}); } /** * 浏览器资源初始化 */ async init() { await this.#asyncLock.acquire("init", async () => { let executablePath; if (_.isString(this.executablePath)) executablePath = this.executablePath; else ({ executablePath } = await installBrowser()); // 启动浏览器 this.target = await puppeteer.launch({ // BeginFrameControl必需处于无头模式下可用,新无头"new"暂时不可用,请关注进展:https://bugs.chromium.org/p/chromium/issues/detail?id=1480747 headless: _.defaultTo(globalConfig.browserHeadless, true), // 浏览器入口文件路径 executablePath, // 忽略HTTPS错误 ignoreHTTPSErrors: true, // 浏览器启动超时时间(毫秒) timeout: _.defaultTo(globalConfig.browserLaunchTimeout, 30000), // 是否输出调试信息到控制台 dumpio: _.defaultTo(globalConfig.browserDebug, false), // 是否使用管道通信 pipe: false, // 协议超时时间(毫秒) protocolTimeout: _.defaultTo(globalConfig.browserProtocolTimeout, 180000), // 用户目录路径 userDataDir: "tmp/browser", // 浏览器启动参数 args: this.#generateArgs() }); // 浏览器关闭时自动处理 this.target.once("disconnected", () => { this.close() .catch(err => logger.error(`Browser ${this.id} close error:`, err)); }); // 创建页面池 this.#createPagePool(); // 预热页面池 await this.#warmupPagePool(); // 启动回调 this.#launchCallbacks.forEach(fn => fn()); // 设置浏览器状态为已就绪 this.#setState(Browser.STATE.READY); }); } /** * 创建页面池 */ #createPagePool() { this.#pagePool = genericPool.createPool({ create: this.#createPage.bind(this), destroy: target => target.close(), validate: target => target.isReady() }, { max: this.numPageMax, min: this.numPageMin, autostart: false }); this.#pagePool.on('factoryCreateError', (error) => { const client = this.#pagePool._waitingClientsQueue.dequeue(); if (!client) return logger.error(error); client.reject(error); }); } /** * 预热页面池 */ async #warmupPagePool() { this.#pagePool.start(); await this.#pagePool.ready(); } /** * 获取可用页面资源 * * @returns {Page} - 页面资源 */ async acquirePage() { return await this.#pagePool.acquire(); } /** * 创建页面资源 * * @returns {Page} - 页面资源 */ async #createPage() { if (!this.target) await new Promise(resolve => this.#launchCallbacks.push(resolve)); const page = new Page(this, { ...this.pageOptions, _firstPage: this.firstPage }); await page.init(); return page; } /** * 释放页面资源 * * @param {Page} page - 页面资源 */ async releasePage(page) { await this.#pagePool.release(page); } /** * 销毁页面资源 * * @param {Page} page - 页面资源 */ async destoryPage(page) { await this.#pagePool.destroy(page); } /** * 释放浏览器资源 */ async release() { await this.#asyncLock.acquire("release", async () => { // 通知浏览器资源池释放资源 await this.parent.releaseBrowser(this); // 设置浏览器状态为已就绪 this.#setState(Browser.STATE.READY); }); } /** * 关闭浏览器 */ async close() { await this.#asyncLock.acquire("close", async () => { if (this.isClosed()) return; // 设置浏览器状态为已关闭 this.#setState(Browser.STATE.CLOSED); // 清除页面池资源 await this.#pagePool.clear(); // 通知浏览器资源池销毁资源 await this.parent.destoryBrowser(this); // 如果浏览器已关闭则跳过 if (!this.target || this.target.isClosed()) return; this.target.close(); this.target = null; }); } /** * 获取浏览器页面数量 * * @returns {number} - 页面数量 */ async getPageCount() { return (await this.target.pages()).length; } /** * 生成浏览器启动参数 * * @returns {Array} - 参数列表 */ #generateArgs() { return [ // 禁用沙箱 "--no-sandbox", // 禁用UID沙箱 "--disable-setuid-sandbox", // Windows下--single-process支持存在问题 util.isLinux() ? "--single-process" : "--process-per-tab", // 如果共享内存/dev/shm比较小,可能导致浏览器无法启动,可以禁用它 ...(this.disableDevShm ? ["--disable-dev-shm-usage"] : []), // 禁用扩展程序 "--disable-extensions", // 隐藏滚动条 "--hide-scrollbars", // 静音 "--mute-audio", // 禁用Web安全策略 "--disable-web-security", // 禁用小恐龙彩蛋 "--disable-dinosaur-easter-egg", // 禁用IPC泛洪保护 "--disable-ipc-flooding-protection", // 禁用降低后台标签页优先级 "--disable-backgrounding-occluded-windows", // 禁用后台标签页定时器节流 "--disable-background-timer-throttling", // 禁用渲染器进程后台化 "--disable-renderer-backgrounding", // 禁用组件更新 "--disable-component-update", // 禁用崩溃报告系统 "--disable-breakpad", // 禁用ping元素 "--no-pings", // 禁用信息栏 "--disable-infobars", // 禁用会话崩溃气泡 "--disable-session-crashed-bubble", // 禁用字形提示以原始轮廓渲染 "--font-render-hinting=none", // 允许在HTTPS页面中加载不安全的HTTP内容 "--allow-running-insecure-content", // 禁用默认浏览器检查 "--no-default-browser-check", // 禁用弹窗 "--block-new-web-contents", // 禁用错误对话框 "--noerrdialogs", // 启用平滑滚动 "--enable-smooth-scrolling", // 禁用线程动画避免动画不同步 "--disable-threaded-animation", // 禁用线程滚动避免动画不同步 "--disable-threaded-scrolling", // 启用表面同步 "--enable-surface-synchronization", // 强制所有内容完整渲染 "--disable-new-content-rendering-timeout", // 禁用渲染器代码完整性,避免因为STATUS_ACCESS_VIOLATION导致页面崩溃 "--disable-features=RendererCodeIntegrity", ...(!globalConfig.browserFrameRateLimit ? [ // 解除帧率限制 "--disable-frame-rate-limit", ] : []), ...(!globalConfig.compatibleRenderingMode ? [ // 启用确定性模式 "--deterministic-mode", // 开启beginFrame控制 "--enable-begin-frame-control", // 在呈现所有内容之前防止绘制下一帧 "--run-all-compositor-stages-before-draw", ] : []), // 是否使用Angle作为渲染后端 ...(this.useAngle ? ["--use-angle"] : []), // 是否使用GPU加速渲染 ...(this.useGPU ? [ // 启用GPU "--enable-gpu", // 启用不安全的WebGPU "--enable-unsafe-webgpu", // 忽略GPU黑名单,在黑名单的GPU渲染时可能会发生非预期效果 "--ignore-gpu-blocklist", // 图形上下文丢失时不重载页面 "--gpu-no-context-lost", // 启用GPU合成功能 "--enable-gpu-compositing", // 启用GPU栅格化加速绘制 "--enable-gpu-rasterization", // 禁用GPU驱动程序错误处理工作 // "--disable-gpu-driver-bug-workarounds", // 启用GPU内存缓冲区提高图像处理性能 "--enable-native-gpu-memory-buffers", // 启用2D画布加速功能 "--enable-accelerated-2d-canvas", // 启用JPEG解码加速功能 "--enable-accelerated-jpeg-decoding", // 启用MJPEG解码加速功能 "--enable-accelerated-mjpeg-decode", // 启用视频解码加速功能 "--enable-accelerated-video-decode", // 启用零拷贝渲染 "--enable-zero-copy", // 将页面渲染栅格化操作移动到单独的进程中执行 "--enable-oop-rasterization", // 启用GPU内存缓冲区缓存视频帧 "--enable-gpu-memory-buffer-video-frames", // 启用VA-API视频解码器支持、原始绘制支持、Canvas独立进程栅格化、HEVC视频解码器支持 "--enable-features=VaapiVideoDecoder,RawDraw,CanvasOopRasterization,PlatformHEVCDecoderSupport" ] : ["--disable-gpu"]), // 其它参数 ...this.args ]; } /** * 设置浏览器资源状态 * * @param {Browser.STATE} state - 浏览器资源状态 */ #setState(state) { assert(_.isSymbol(state), "state must be Symbol"); this.state = state; } /** * 是否未初始化 * * @returns {boolean} - 是否未初始化 */ isUninitialized() { return this.state == Browser.STATE.UNINITIALIZED; } /** * 是否已就绪 * * @returns {boolean} - 是否已就绪 */ isReady() { return this.state == Browser.STATE.READY; } /** * 是否不可用 * * @returns {boolean} - 是否不可用 */ isUnavailabled() { return this.state == Browser.STATE.UNAVAILABLED; } /** * 是否已关闭 * * @returns {boolean} - 是否已关闭 */ isClosed() { return this.state == Browser.STATE.CLOSED; } /** * 判断页面资源池是否饱和 * * @returns {boolean} 页面池是否饱和 */ isBusy() { return this.#pagePool.borrowed >= this.#pagePool.max; } /** * 获取是否首个页面 * * @returns {boolean} 是否首个页面 */ get firstPage() { if (!this.#firstPage) return false; this.#firstPage = false; return true; } /** * 获取视频预处理器 */ get videoPreprocessor() { return this.parent.videoPreprocessor; } }