UNPKG

web-video-creator

Version:

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

265 lines (251 loc) 11.4 kB
import assert from "assert"; import _ from "lodash"; import AsyncLock from "async-lock"; import VideoChunk from "../core/VideoChunk.js"; import Transition from "../entity/Transition.js"; import Page from "../core/Page.js"; import Font from "../entity/Font.js"; import logger from "../lib/logger.js"; import util from "../lib/util.js"; /** * @typedef {import('puppeteer-core').WaitForOptions} WaitForOptions * @typedef {import('puppeteer-core').Viewport} Viewport */ /** * 分块视频 */ export default class ChunkVideo extends VideoChunk { /** @type {string} - 页面URL */ url; /** @type {string} - 页面内容 */ content; /** @type {number} - 开始捕获时间点 */ startTime; /** @type {Font[]} - 注册的字体 */ fonts = []; /** @type {boolean} - 是否自动启动渲染 */ autostartRender; /** @type {boolean} - 是否输出页面控制台日志 */ consoleLog; /** @type {boolean} - 是否输出视频预处理日志 */ videoPreprocessLog; /** @type {Viewport} - 页面视窗参数 */ pageViewport; /** @type {Function} - 页面预处理函数 */ pagePrepareFn; /** @type {{[key: number]: Function}} - 动作序列 */ timeActions; /** @type {Function} - 页面获取函数 */ #pageAcquireFn = null; /** @type {AsyncLock} - 异步锁 */ #asyncLock = new AsyncLock(); /** * 构造函数 * * @param {Object} options - 分块视频选项 * @param {string} [options.url] - 页面URL * @param {string} [options.content] - 页面内容 * @param {string} options.outputPath - 输出路径 * @param {number} options.width - 视频宽度 * @param {number} options.height - 视频高度 * @param {number} options.duration - 视频时长 * @param {number} [options.startTime] - 开始捕获时间点 * @param {number} [options.fps=30] - 视频帧率 * @param {string|Transition} [options.transition] - 进入下一视频分块的转场效果 * @param {string} [options.format] - 导出视频格式(mp4/webm) * @param {string} [options.attachCoverPath] - 附加到视频首帧的封面路径 * @param {string} [options.coverCapture=false] - 是否捕获封面并输出 * @param {number} [options.coverCaptureTime] - 封面捕获时间点(毫秒) * @param {string} [options.coverCaptureFormat="jpg"] - 封面捕获格式(jpg/png/bmp) * @param {string} [options.videoEncoder="libx264"] - 视频编码器 * @param {number} [options.videoQuality=100] - 视频质量(0-100) * @param {string} [options.videoBitrate] - 视频码率(设置码率将忽略videoQuality) * @param {string} [options.pixelFormat="yuv420p"] - 像素格式(yuv420p/yuv444p/rgb24) * @param {string} [options.audioEncoder="aac"] - 音频编码器 * @param {string} [options.audioBitrate] - 音频码率 * @param {number} [options.volume] - 视频音量(0-100) * @param {number} [options.parallelWriteFrames=10] - 并行写入帧数 * @param {boolean} [options.showProgress=false] - 是否在命令行展示进度 * @param {boolean} [options.backgroundOpacity=1] - 背景不透明度(0-1),仅webm格式支持 * @param {boolean} [options.autostartRender=true] - 是否自动启动渲染,如果为false请务必在页面中执行 captureCtx.start() * @param {boolean} [options.consoleLog=false] - 是否开启控制台日志输出 * @param {boolean} [options.videoPreprocessLog=false] - 是否开启视频预处理日志输出 * @param {WaitForOptions} [options.pageWaitForOptions] - 页面等待选项 * @param {Viewport} [options.pageViewport] - 页面视窗参数 * @param {Function} [options.pagePrepareFn] - 页面预处理函数 * @param {Function} [options.pagePrepareFn] - 页面预处理函数 */ constructor(options = {}) { super(options); assert(_.isObject(options), "options must be Object"); const { url, content, startTime, autostartRender, consoleLog, videoPreprocessLog, pageWaitForOptions, pageViewport, pagePrepareFn, timeActions } = options; assert(_.isUndefined(url) || util.isURL(url), `url ${url} is not valid URL`); assert(_.isUndefined(content) || _.isString(content), "page content must be string"); assert(!_.isUndefined(url) || !_.isUndefined(content), "page url or content must be provide"); assert(_.isUndefined(startTime) || _.isFinite(startTime), "startTime must be number"); assert(_.isUndefined(autostartRender) || _.isBoolean(autostartRender), "autostartRender must be boolean"); assert(_.isUndefined(consoleLog) || _.isBoolean(consoleLog), "consoleLog must be boolean"); assert(_.isUndefined(pageWaitForOptions) || _.isObject(pageWaitForOptions), "pageWaitForOptions must be Object"); assert(_.isUndefined(pageViewport) || _.isObject(pageViewport), "pageViewport must be Object"); assert(_.isUndefined(pagePrepareFn) || _.isFunction(pagePrepareFn), "pagePrepareFn must be Function"); assert(_.isUndefined(timeActions) || _.isObject(timeActions), "timeActions must be Object"); timeActions && Object.keys(timeActions).forEach(key => { key = Number(key) assert(_.isFinite(key), `timeActions key ${key} must be Number`); assert(_.isFunction(timeActions[key]), `timeActions[${key}] must be Function`); }) this.url = url; this.content = content; this.startTime = startTime; this.autostartRender = _.defaultTo(autostartRender, true); this.consoleLog = _.defaultTo(consoleLog, false); this.videoPreprocessLog = _.defaultTo(videoPreprocessLog, false); this.pageWaitForOptions = pageWaitForOptions; this.pageViewport = pageViewport; this.pagePrepareFn = pagePrepareFn; this.timeActions = timeActions; } /** * 启动合成 */ start() { this.#asyncLock.acquire("start", () => this.#synthesize()) .catch(err => logger.error(err)); } /** * 启动并等待完成 */ async startAndWait() { await this.#asyncLock.acquire("start", () => this.#synthesize()); } /** * 注册字体 * * @param {Font} font - 字体对象 */ registerFont(font) { if (!(font instanceof Font)) font = new Font(font); // 开始加载字体 font.load(); this.fonts.push(font); } /** * 注册多个字体 * * @param {Font[]} fonts - 字体对象列表 */ registerFonts(fonts = []) { fonts.forEach(font => this.registerFont(font)); } /** * 合成处理 */ async #synthesize() { const page = await this.#acquirePage(); try { const { url, content, width, height, pageWaitForOptions, pageViewport = {} } = this; // 监听页面实例发生的某些内部错误 page.on("error", err => this._emitError("Page error:\n" + err.stack)); // 监听页面是否崩溃,当内存不足或过载时可能会崩溃 page.on("crashed", err => this.#emitPageCrashedError(err)); if (this.consoleLog) { // 监听页面打印到console的正常日志 page.on("consoleLog", message => logger.log("[page]", message)); // 监听页面打印到console的错误日志 page.on("consoleError", err => logger.error("[page]", err)); } if (this.videoPreprocessLog) page.on("videoPreprocess", config => logger.log("[video_preprocess]", config.url)); page.on("audioAdd", options => { this.addAudio(options); this.emit("audioAdd", options); }); page.on("audioUpdate", (audioId, options) => { this.updateAudio(audioId, options); this.emit("audioUpdate", options); }) // 设置视窗宽高 await page.setViewport({ width, height, ...pageViewport }); // 跳转到您希望渲染的页面,您可以考虑创建一个本地的Web服务器提供页面以提升加载速度和安全性 if (url) await page.goto(url, pageWaitForOptions); // 或者设置页面内容 else await page.setContent(content, pageWaitForOptions); // 存在透明通道时设置背景透明度 this.hasAlphaChannel && page.setBackgroundOpacity(this.backgroundOpacity); // 存在预处理函数时先执行预处理 this.pagePrepareFn && await this.pagePrepareFn(page); // 注册字体 if (this.fonts.length > 0) page.registerFonts(this.fonts); // 等待字体加载完成 await page.waitForFontsLoaded(); // 注册事件序列 if (this.timeActions && Object.keys(this.timeActions).length > 0) page.registerTimeActions(this.timeActions); // 启动合成 super.start(); // 合成完成promise const completedPromise = new Promise(resolve => this.once("completed", resolve)); // 监听已渲染的帧输入到合成器 page.on("frame", buffer => this.input(buffer)); // 启动捕获 await page.startScreencast({ fps: this.fps, startTime: this.startTime, duration: this.duration, autostart: this.autostartRender }); // 监听并等待录制完成 await new Promise(resolve => page.once("screencastCompleted", resolve)); // 停止录制 await page.stopScreencast(); // 释放页面资源 await page.release(); // 告知合成器结束输入 this.endInput(); // 等待合成完成 await completedPromise; } catch (err) { await page.release(); this._emitError(err); } } /** * 注册页面获取函数 * * @param {Function} fn */ onPageAcquire(fn) { assert(_.isFunction(fn), "Page acquire function must be Function"); this.#pageAcquireFn = fn; } /** * 获取渲染页面 * * @protected * @returns {Page} - 页面对象 */ async #acquirePage() { assert(_.isFunction(this.#pageAcquireFn), "Page acquire function must be Function"); return await this.#pageAcquireFn(); } /** * 发送页面崩溃错误 * * @param {Error} err - 错误对象 */ #emitPageCrashedError(err) { if (this.eventNames().includes("pageCrashed")) this.emit("pageCrashed", err); else logger.error("Page crashed:\n" + err.stack); } }