├── .gitignore ├── LICENSE ├── README.en-US.md ├── README.md ├── api ├── ChunkVideo.js ├── MultiVideo.js ├── SingleVideo.js ├── WebVideoCreator.js └── index.js ├── assets ├── chunk-video.gif ├── demo.gif ├── multi-video.gif ├── single-video.gif └── web-video-creator.png ├── core ├── Browser.js ├── CaptureContext.js ├── ChunkSynthesizer.js ├── Page.js ├── ResourcePool.js ├── Synthesizer.js ├── VideoChunk.js └── index.js ├── docs ├── api-reference-high-level.md ├── api-reference-low-level.md ├── capture-ctx.md ├── renderer-env.md ├── transition.md └── video-encoder.md ├── entity ├── Audio.js ├── Font.js ├── Transition.js └── index.js ├── examples ├── index.js ├── multi-video.js └── single-video.js ├── index.js ├── lib ├── cleaner.js ├── common.css ├── const.js ├── fontfaceobserver.js ├── global-config.js ├── inner-util.js ├── install-browser.js ├── logger.js ├── lottie.js ├── mp4box.js └── util.js ├── media ├── DynamicImage.js ├── LottieCanvas.js ├── MP4Demuxer.js ├── SvgAnimation.js └── VideoCanvas.js ├── package.json └── preprocessor ├── base ├── DownloadTask.js ├── Preprocessor.js ├── ProcessTask.js └── Task.js └── video ├── VideoConfig.js ├── VideoDownloadTask.js ├── VideoPreprocessor.js └── VideoProcessTask.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | output 3 | tmp 4 | .bin 5 | *.tmp 6 | *.mp3 7 | *.mp4 8 | *.wav 9 | *.ts 10 | *.webm 11 | *.jpg 12 | *.bmp 13 | test.js 14 | .DS_Store 15 | yarn.lock 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /api/ChunkVideo.js: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import _ from "lodash"; 3 | import AsyncLock from "async-lock"; 4 | 5 | import VideoChunk from "../core/VideoChunk.js"; 6 | import Transition from "../entity/Transition.js"; 7 | import Page from "../core/Page.js"; 8 | import Font from "../entity/Font.js"; 9 | import logger from "../lib/logger.js"; 10 | import util from "../lib/util.js"; 11 | 12 | /** 13 | * @typedef {import('puppeteer-core').WaitForOptions} WaitForOptions 14 | * @typedef {import('puppeteer-core').Viewport} Viewport 15 | */ 16 | 17 | /** 18 | * 分块视频 19 | */ 20 | export default class ChunkVideo extends VideoChunk { 21 | 22 | /** @type {string} - 页面URL */ 23 | url; 24 | /** @type {string} - 页面内容 */ 25 | content; 26 | /** @type {number} - 开始捕获时间点 */ 27 | startTime; 28 | /** @type {Font[]} - 注册的字体 */ 29 | fonts = []; 30 | /** @type {boolean} - 是否自动启动渲染 */ 31 | autostartRender; 32 | /** @type {boolean} - 是否输出页面控制台日志 */ 33 | consoleLog; 34 | /** @type {boolean} - 是否输出视频预处理日志 */ 35 | videoPreprocessLog; 36 | /** @type {Viewport} - 页面视窗参数 */ 37 | pageViewport; 38 | /** @type {Function} - 页面预处理函数 */ 39 | pagePrepareFn; 40 | /** @type {{[key: number]: Function}} - 动作序列 */ 41 | timeActions; 42 | /** @type {Function} - 终止回调函数 */ 43 | #abortCallback = null; 44 | /** @type {Function} - 页面获取函数 */ 45 | #pageAcquireFn = null; 46 | /** @type {AsyncLock} - 异步锁 */ 47 | #asyncLock = new AsyncLock(); 48 | 49 | /** 50 | * 构造函数 51 | * 52 | * @param {Object} options - 分块视频选项 53 | * @param {string} [options.url] - 页面URL 54 | * @param {string} [options.content] - 页面内容 55 | * @param {string} options.outputPath - 输出路径 56 | * @param {number} options.width - 视频宽度 57 | * @param {number} options.height - 视频高度 58 | * @param {number} options.duration - 视频时长 59 | * @param {number} [options.startTime] - 开始捕获时间点 60 | * @param {number} [options.fps=30] - 视频帧率 61 | * @param {string|Transition} [options.transition] - 进入下一视频分块的转场效果 62 | * @param {string} [options.format] - 导出视频格式(mp4/webm) 63 | * @param {string} [options.attachCoverPath] - 附加到视频首帧的封面路径 64 | * @param {string} [options.coverCapture=false] - 是否捕获封面并输出 65 | * @param {number} [options.coverCaptureTime] - 封面捕获时间点(毫秒) 66 | * @param {string} [options.coverCaptureFormat="jpg"] - 封面捕获格式(jpg/png/bmp) 67 | * @param {string} [options.videoEncoder="libx264"] - 视频编码器 68 | * @param {number} [options.videoQuality=100] - 视频质量(0-100) 69 | * @param {string} [options.videoBitrate] - 视频码率(设置码率将忽略videoQuality) 70 | * @param {string} [options.pixelFormat="yuv420p"] - 像素格式(yuv420p/yuv444p/rgb24) 71 | * @param {string} [options.audioEncoder="aac"] - 音频编码器 72 | * @param {string} [options.audioBitrate] - 音频码率 73 | * @param {number} [options.volume] - 视频音量(0-100) 74 | * @param {number} [options.parallelWriteFrames=10] - 并行写入帧数 75 | * @param {boolean} [options.showProgress=false] - 是否在命令行展示进度 76 | * @param {boolean} [options.backgroundOpacity=1] - 背景不透明度(0-1),仅webm格式支持 77 | * @param {boolean} [options.autostartRender=true] - 是否自动启动渲染,如果为false请务必在页面中执行 captureCtx.start() 78 | * @param {boolean} [options.consoleLog=false] - 是否开启控制台日志输出 79 | * @param {boolean} [options.videoPreprocessLog=false] - 是否开启视频预处理日志输出 80 | * @param {string} [options.videoDecoderHardwareAcceleration] - VideoDecoder硬件加速指示 81 | * @param {WaitForOptions} [options.pageWaitForOptions] - 页面等待选项 82 | * @param {Viewport} [options.pageViewport] - 页面视窗参数 83 | * @param {Function} [options.pagePrepareFn] - 页面预处理函数 84 | * @param {Function} [options.pagePrepareFn] - 页面预处理函数 85 | */ 86 | constructor(options = {}) { 87 | super(options); 88 | assert(_.isObject(options), "options must be Object"); 89 | const { url, content, startTime, autostartRender, consoleLog, videoPreprocessLog, pageWaitForOptions, pageViewport, pagePrepareFn, videoDecoderHardwareAcceleration, timeActions } = options; 90 | assert(_.isUndefined(url) || util.isURL(url), `url ${url} is not valid URL`); 91 | assert(_.isUndefined(content) || _.isString(content), "page content must be string"); 92 | assert(!_.isUndefined(url) || !_.isUndefined(content), "page url or content must be provide"); 93 | assert(_.isUndefined(startTime) || _.isFinite(startTime), "startTime must be number"); 94 | assert(_.isUndefined(autostartRender) || _.isBoolean(autostartRender), "autostartRender must be boolean"); 95 | assert(_.isUndefined(consoleLog) || _.isBoolean(consoleLog), "consoleLog must be boolean"); 96 | assert(_.isUndefined(pageWaitForOptions) || _.isObject(pageWaitForOptions), "pageWaitForOptions must be Object"); 97 | assert(_.isUndefined(pageViewport) || _.isObject(pageViewport), "pageViewport must be Object"); 98 | assert(_.isUndefined(pagePrepareFn) || _.isFunction(pagePrepareFn), "pagePrepareFn must be Function"); 99 | assert(_.isUndefined(videoDecoderHardwareAcceleration) || _.isString(videoDecoderHardwareAcceleration), "videoDecoderHardwareAcceleration must be string"); 100 | assert(_.isUndefined(timeActions) || _.isObject(timeActions), "timeActions must be Object"); 101 | timeActions && Object.keys(timeActions).forEach(key => { 102 | key = Number(key) 103 | assert(_.isFinite(key), `timeActions key ${key} must be Number`); 104 | assert(_.isFunction(timeActions[key]), `timeActions[${key}] must be Function`); 105 | }) 106 | this.url = url; 107 | this.content = content; 108 | this.startTime = startTime; 109 | this.autostartRender = _.defaultTo(autostartRender, true); 110 | this.consoleLog = _.defaultTo(consoleLog, false); 111 | this.videoPreprocessLog = _.defaultTo(videoPreprocessLog, false); 112 | this.pageWaitForOptions = pageWaitForOptions; 113 | this.pageViewport = pageViewport; 114 | this.pagePrepareFn = pagePrepareFn; 115 | this.videoDecoderHardwareAcceleration = videoDecoderHardwareAcceleration; 116 | this.timeActions = timeActions; 117 | } 118 | 119 | /** 120 | * 启动合成 121 | */ 122 | start() { 123 | this.#abortCallback = null; 124 | this.#asyncLock.acquire("start", () => this.#synthesize()) 125 | .catch(err => logger.error(err)); 126 | } 127 | 128 | /** 129 | * 启动并等待完成 130 | */ 131 | async startAndWait() { 132 | await this.#asyncLock.acquire("start", () => this.#synthesize()); 133 | } 134 | 135 | /** 136 | * 终止捕获 137 | */ 138 | abort() { 139 | if(!this.#abortCallback) 140 | return this.#abortCallback; 141 | this.#abortCallback(); 142 | } 143 | 144 | /** 145 | * 注册字体 146 | * 147 | * @param {Font} font - 字体对象 148 | */ 149 | registerFont(font) { 150 | if (!(font instanceof Font)) 151 | font = new Font(font); 152 | // 开始加载字体 153 | font.load(); 154 | this.fonts.push(font); 155 | } 156 | 157 | /** 158 | * 注册多个字体 159 | * 160 | * @param {Font[]} fonts - 字体对象列表 161 | */ 162 | registerFonts(fonts = []) { 163 | fonts.forEach(font => this.registerFont(font)); 164 | } 165 | 166 | /** 167 | * 合成处理 168 | */ 169 | async #synthesize() { 170 | const page = await this.#acquirePage(); 171 | try { 172 | const { url, content, width, height, pageWaitForOptions, pageViewport = {} } = this; 173 | // 监听页面实例发生的某些内部错误 174 | page.on("error", err => this._emitError("Page error:\n" + err.stack)); 175 | // 监听页面是否崩溃,当内存不足或过载时可能会崩溃 176 | page.on("crashed", err => this.#emitPageCrashedError(err)); 177 | if (this.consoleLog) { 178 | // 监听页面打印到console的正常日志 179 | page.on("consoleLog", message => logger.log("[page]", message)); 180 | // 监听页面打印到console的错误日志 181 | page.on("consoleError", err => logger.error("[page]", err)); 182 | } 183 | if (this.videoPreprocessLog) 184 | page.on("videoPreprocess", config => logger.log("[video_preprocess]", config.url)); 185 | page.on("audioAdd", options => { 186 | this.addAudio(options); 187 | this.emit("audioAdd", options); 188 | }); 189 | page.on("audioUpdate", (audioId, options) => { 190 | this.updateAudio(audioId, options); 191 | this.emit("audioUpdate", options); 192 | }) 193 | // 设置视窗宽高 194 | await page.setViewport({ 195 | width, 196 | height, 197 | ...pageViewport 198 | }); 199 | // 跳转到您希望渲染的页面,您可以考虑创建一个本地的Web服务器提供页面以提升加载速度和安全性 200 | if (url) 201 | await page.goto(url, pageWaitForOptions); 202 | // 或者设置页面内容 203 | else 204 | await page.setContent(content, pageWaitForOptions); 205 | // 存在透明通道时设置背景透明度 206 | this.hasAlphaChannel && page.setBackgroundOpacity(this.backgroundOpacity); 207 | // 存在预处理函数时先执行预处理 208 | this.pagePrepareFn && await this.pagePrepareFn(page); 209 | // 注册字体 210 | if (this.fonts.length > 0) 211 | page.registerFonts(this.fonts); 212 | // 等待字体加载完成 213 | await page.waitForFontsLoaded(); 214 | // 注册事件序列 215 | if (this.timeActions && Object.keys(this.timeActions).length > 0) 216 | page.registerTimeActions(this.timeActions); 217 | // 注册终止回调 218 | this.#abortCallback = () => page.target.evaluate(() => captureCtx.abort()).catch(err => console.error(err)); 219 | // 启动合成 220 | super.start(); 221 | // 合成完成promise 222 | const completedPromise = new Promise(resolve => this.once("completed", resolve)); 223 | // 监听已渲染的帧输入到合成器 224 | page.on("frame", buffer => this.input(buffer)); 225 | // 启动捕获 226 | await page.startScreencast({ 227 | fps: this.fps, 228 | startTime: this.startTime, 229 | duration: this.duration, 230 | videoDecoderHardwareAcceleration: this.videoDecoderHardwareAcceleration, 231 | autostart: this.autostartRender 232 | }); 233 | // 监听并等待录制完成 234 | await new Promise(resolve => page.once("screencastCompleted", resolve)); 235 | // 停止录制 236 | await page.stopScreencast(); 237 | // 释放页面资源 238 | await page.release(); 239 | // 告知合成器结束输入 240 | this.endInput(); 241 | // 等待合成完成 242 | await completedPromise; 243 | } 244 | catch (err) { 245 | await page.release(); 246 | this._emitError(err); 247 | } 248 | } 249 | 250 | /** 251 | * 注册页面获取函数 252 | * 253 | * @param {Function} fn 254 | */ 255 | onPageAcquire(fn) { 256 | assert(_.isFunction(fn), "Page acquire function must be Function"); 257 | this.#pageAcquireFn = fn; 258 | } 259 | 260 | /** 261 | * 获取渲染页面 262 | * 263 | * @protected 264 | * @returns {Page} - 页面对象 265 | */ 266 | async #acquirePage() { 267 | assert(_.isFunction(this.#pageAcquireFn), "Page acquire function must be Function"); 268 | return await this.#pageAcquireFn(); 269 | } 270 | 271 | /** 272 | * 发送页面崩溃错误 273 | * 274 | * @param {Error} err - 错误对象 275 | */ 276 | #emitPageCrashedError(err) { 277 | if (this.eventNames().includes("pageCrashed")) 278 | this.emit("pageCrashed", err); 279 | else 280 | logger.error("Page crashed:\n" + err.stack); 281 | } 282 | 283 | } -------------------------------------------------------------------------------- /api/MultiVideo.js: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import AsyncLock from "async-lock"; 3 | import _ from "lodash"; 4 | 5 | import ChunkSynthesizer from "../core/ChunkSynthesizer.js"; 6 | import ChunkVideo from "./ChunkVideo.js"; 7 | import Font from "../entity/Font.js"; 8 | import logger from "../lib/logger.js"; 9 | 10 | /** 11 | * 多幕视频 12 | */ 13 | export default class MultiVideo extends ChunkSynthesizer { 14 | 15 | /** @type {number} - 开始捕获时间点 */ 16 | startTime; 17 | /** @type {Font[]} - 注册的字体 */ 18 | fonts = []; 19 | /** @type {Function} - 页面预处理函数 */ 20 | pagePrepareFn; 21 | /** @type {{[key: number]: Function}} - 动作序列 */ 22 | timeActions; 23 | /** @type {Function} - 页面获取函数 */ 24 | #pageAcquireFn = null; 25 | /** @type {AsyncLock} - 异步锁 */ 26 | #asyncLock = new AsyncLock(); 27 | 28 | /** 29 | * 构造函数 30 | * 31 | * @param {Object} options - 序列帧合成器选项 32 | * @param {string} options.outputPath - 导出视频路径 33 | * @param {number} options.width - 视频宽度 34 | * @param {number} options.height - 视频高度 35 | * @param {ChunkVideo[]} options.chunks - 分块视频列表 36 | * @param {number} [options.startTime=0] - 开始捕获时间点 37 | * @param {number} [options.fps=30] - 视频合成帧率 38 | * @param {string} [options.format] - 导出视频格式(mp4/webm) 39 | * @param {string} [options.attachCoverPath] - 附加到视频首帧的封面路径 40 | * @param {string} [options.coverCapture=false] - 是否捕获封面并输出 41 | * @param {number} [options.coverCaptureTime] - 封面捕获时间点(毫秒) 42 | * @param {string} [options.coverCaptureFormat="jpg"] - 封面捕获格式(jpg/png/bmp) 43 | * @param {string} [options.videoEncoder="libx264"] - 视频编码器 44 | * @param {number} [options.videoQuality=100] - 视频质量(0-100) 45 | * @param {string} [options.videoBitrate] - 视频码率(设置码率将忽略videoQuality) 46 | * @param {string} [options.pixelFormat="yuv420p"] - 像素格式(yuv420p/yuv444p/rgb24) 47 | * @param {string} [options.audioEncoder="aac"] - 音频编码器 48 | * @param {string} [options.audioBitrate] - 音频码率 49 | * @param {number} [options.volume] - 视频音量(0-100) 50 | * @param {number} [options.parallelWriteFrames=10] - 并行写入帧数 51 | * @param {boolean} [options.showProgress=false] - 是否在命令行展示进度 52 | * @param {Function} [options.pagePrepareFn] - 页面预处理函数 53 | */ 54 | constructor(options) { 55 | super(options); 56 | const { startTime, pagePrepareFn } = options; 57 | assert(_.isUndefined(startTime) || _.isFinite(startTime), "startTime must be number"); 58 | assert(_.isUndefined(pagePrepareFn) || _.isFunction(pagePrepareFn), "pagePrepareFn must be Function"); 59 | this.startTime = startTime; 60 | this.pagePrepareFn = pagePrepareFn; 61 | } 62 | 63 | /** 64 | * 启动合成 65 | */ 66 | start() { 67 | this.#asyncLock.acquire("start", () => this.#synthesize()) 68 | .catch(err => logger.error(err)); 69 | } 70 | 71 | /** 72 | * 启动并等待 73 | */ 74 | async startAndWait() { 75 | await this.#asyncLock.acquire("start", () => this.#synthesize()); 76 | } 77 | 78 | /** 79 | * 输入分块视频 80 | * 81 | * @param {ChunkVideo} chunk - 分块视频 82 | * @param {Transition} [transition] - 进入下一分块的转场对象 83 | */ 84 | input(chunk, transition) { 85 | _.isFinite(this.width) && (chunk.width = _.defaultTo(chunk.width, this.width)); 86 | _.isFinite(this.height) && (chunk.height = _.defaultTo(chunk.height, this.height)); 87 | _.isFinite(this.fps) && (chunk.fps = _.defaultTo(chunk.fps, this.fps)); 88 | if (!(chunk instanceof ChunkVideo)) 89 | chunk = new ChunkVideo(chunk); 90 | super.input(chunk, transition); 91 | chunk.onPageAcquire(async () => await this.#acquirePage()); 92 | } 93 | 94 | /** 95 | * 注册字体 96 | * 97 | * @param {Font} font - 字体对象 98 | */ 99 | registerFont(font) { 100 | if (!(font instanceof Font)) 101 | font = new Font(font); 102 | // 开始加载字体 103 | font.load(); 104 | this.fonts.push(font); 105 | } 106 | 107 | /** 108 | * 注册多个字体 109 | * 110 | * @param {Font[]} fonts - 字体对象列表 111 | */ 112 | registerFonts(fonts = []) { 113 | fonts.forEach(font => this.registerFont(font)); 114 | } 115 | 116 | /** 117 | * 合成处理 118 | */ 119 | async #synthesize() { 120 | this.chunks.forEach(chunk => { 121 | if (_.isUndefined(chunk.startTime) && this.startTime) 122 | chunk.startTime = this.startTime; 123 | if (_.isUndefined(chunk.pagePrepareFn) && this.pagePrepareFn) 124 | chunk.pagePrepareFn = this.pagePrepareFn; 125 | if (this.fonts.length > 0) 126 | chunk.registerFonts(this.fonts); 127 | }); 128 | return await new Promise((resolve, reject) => { 129 | this.once("error", reject); 130 | this.once("completed", resolve); 131 | super.start(); 132 | }); 133 | } 134 | 135 | /** 136 | * 注册页面获取函数 137 | * 138 | * @param {Function} fn 139 | */ 140 | onPageAcquire(fn) { 141 | assert(_.isFunction(fn), "Page acquire function must be Function"); 142 | this.#pageAcquireFn = fn; 143 | } 144 | 145 | /** 146 | * 获取渲染页面 147 | * 148 | * @protected 149 | * @returns {Page} - 页面对象 150 | */ 151 | async #acquirePage() { 152 | assert(_.isFunction(this.#pageAcquireFn), "Page acquire function must be Function"); 153 | return await this.#pageAcquireFn(); 154 | } 155 | 156 | } -------------------------------------------------------------------------------- /api/SingleVideo.js: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import _ from "lodash"; 3 | import AsyncLock from "async-lock"; 4 | 5 | import Synthesizer from "../core/Synthesizer.js"; 6 | import Page from "../core/Page.js"; 7 | import Font from "../entity/Font.js"; 8 | import logger from "../lib/logger.js"; 9 | import util from "../lib/util.js"; 10 | 11 | /** 12 | * @typedef {import('puppeteer-core').WaitForOptions} WaitForOptions 13 | * @typedef {import('puppeteer-core').Viewport} Viewport 14 | */ 15 | 16 | /** 17 | * 单幕视频 18 | */ 19 | export default class SingleVideo extends Synthesizer { 20 | 21 | /** @type {string} - 页面URL */ 22 | url; 23 | /** @type {string} - 页面内容 */ 24 | content; 25 | /** @type {number} - 开始捕获时间点 */ 26 | startTime; 27 | /** @type {Font[]} - 注册的字体列表 */ 28 | fonts = []; 29 | /** @type {boolean} - 是否自动启动渲染 */ 30 | autostartRender; 31 | /** @type {boolean} - 是否输出页面控制台日志 */ 32 | consoleLog; 33 | /** @type {boolean} - 是否输出视频预处理日志 */ 34 | videoPreprocessLog; 35 | /** @type {Viewport} - 页面视窗参数 */ 36 | pageViewport; 37 | /** @type {Function} - 页面预处理函数 */ 38 | pagePrepareFn; 39 | /** @type {{[key: number]: Function}} - 动作序列 */ 40 | timeActions; 41 | /** @type {Function} - 终止回调函数 */ 42 | #abortCallback = null; 43 | /** @type {Function} - 页面获取函数 */ 44 | #pageAcquireFn = null; 45 | /** @type {AsyncLock} - 异步锁 */ 46 | #asyncLock = new AsyncLock(); 47 | 48 | /** 49 | * 构造函数 50 | * 51 | * @param {Object} options - 单幕视频选项 52 | * @param {string} [options.url] - 页面URL 53 | * @param {string} [options.content] - 页面内容 54 | * @param {string} options.outputPath - 输出路径 55 | * @param {number} options.width - 视频宽度 56 | * @param {number} options.height - 视频高度 57 | * @param {number} options.duration - 视频时长 58 | * @param {number} [options.startTime] - 开始捕获时间点 59 | * @param {number} [options.fps=30] - 视频帧率 60 | * @param {string} [options.format] - 导出视频格式(mp4/webm) 61 | * @param {string} [options.attachCoverPath] - 附加到视频首帧的封面路径 62 | * @param {string} [options.coverCapture=false] - 是否捕获封面并输出 63 | * @param {number} [options.coverCaptureTime] - 封面捕获时间点(毫秒) 64 | * @param {string} [options.coverCaptureFormat="jpg"] - 封面捕获格式(jpg/png/bmp) 65 | * @param {string} [options.videoEncoder="libx264"] - 视频编码器 66 | * @param {number} [options.videoQuality=100] - 视频质量(0-100) 67 | * @param {string} [options.videoBitrate] - 视频码率(设置码率将忽略videoQuality) 68 | * @param {string} [options.pixelFormat="yuv420p"] - 像素格式(yuv420p/yuv444p/rgb24) 69 | * @param {string} [options.audioEncoder="aac"] - 音频编码器 70 | * @param {string} [options.audioBitrate] - 音频码率 71 | * @param {number} [options.volume] - 视频音量(0-100) 72 | * @param {number} [options.parallelWriteFrames=10] - 并行写入帧数 73 | * @param {Viewport} [options.pageViewport] - 页面视窗参数 74 | * @param {Function} [options.pagePrepareFn] - 页面预处理函数 75 | * @param {string} [options.videoDecoderHardwareAcceleration] - VideoDecoder硬件加速指示 76 | * @param {{[key: number]: Function}} [options.timeActions] - 动作序列 77 | * @param {WaitForOptions} [options.pageWaitForOptions] - 页面等待选项 78 | * @param {boolean} [options.showProgress=false] - 是否在命令行展示进度 79 | * @param {boolean} [options.backgroundOpacity=1] - 背景不透明度(0-1),仅webm格式支持 80 | * @param {boolean} [options.autostartRender=true] - 是否自动启动渲染,如果为false请务必在页面中执行 captureCtx.start() 81 | * @param {boolean} [options.consoleLog=false] - 是否开启控制台日志输出 82 | * @param {boolean} [options.videoPreprocessLog=false] - 是否开启视频预处理日志输出 83 | */ 84 | constructor(options = {}) { 85 | super(options); 86 | const { url, content, startTime, autostartRender, consoleLog, videoPreprocessLog, pageWaitForOptions, pageViewport, pagePrepareFn, videoDecoderHardwareAcceleration, timeActions } = options; 87 | assert(_.isUndefined(url) || util.isURL(url), `url ${url} is not valid URL`); 88 | assert(_.isUndefined(content) || _.isString(content), "page content must be string"); 89 | assert(!_.isUndefined(url) || !_.isUndefined(content), "page url or content must be provide"); 90 | assert(_.isUndefined(startTime) || _.isFinite(startTime), "startTime must be number"); 91 | assert(_.isUndefined(autostartRender) || _.isBoolean(autostartRender), "autostartRender must be boolean"); 92 | assert(_.isUndefined(consoleLog) || _.isBoolean(consoleLog), "consoleLog must be boolean"); 93 | assert(_.isUndefined(pageWaitForOptions) || _.isObject(pageWaitForOptions), "pageWaitForOptions must be Object"); 94 | assert(_.isUndefined(pageViewport) || _.isObject(pageViewport), "pageViewport must be Object"); 95 | assert(_.isUndefined(pagePrepareFn) || _.isFunction(pagePrepareFn), "pagePrepareFn must be Function"); 96 | assert(_.isUndefined(videoDecoderHardwareAcceleration) || _.isString(videoDecoderHardwareAcceleration), "videoDecoderHardwareAcceleration must be string"); 97 | assert(_.isUndefined(timeActions) || _.isObject(timeActions), "timeActions must be Object"); 98 | timeActions && Object.keys(timeActions).forEach(key => { 99 | key = Number(key) 100 | assert(_.isFinite(key), `timeActions key ${key} must be Number`); 101 | assert(_.isFunction(timeActions[key]), `timeActions[${key}] must be Function`); 102 | }) 103 | this.url = url; 104 | this.content = content; 105 | this.startTime = startTime; 106 | this.autostartRender = _.defaultTo(autostartRender, true); 107 | this.consoleLog = _.defaultTo(consoleLog, false); 108 | this.videoPreprocessLog = _.defaultTo(videoPreprocessLog, false); 109 | this.pageViewport = pageViewport; 110 | this.pageWaitForOptions = pageWaitForOptions; 111 | this.pagePrepareFn = pagePrepareFn; 112 | this.videoDecoderHardwareAcceleration = videoDecoderHardwareAcceleration; 113 | this.timeActions = timeActions; 114 | } 115 | 116 | /** 117 | * 启动合成 118 | */ 119 | start() { 120 | this.#abortCallback = null; 121 | this.#asyncLock.acquire("start", () => this.#synthesize()) 122 | .catch(err => logger.error(err)); 123 | } 124 | 125 | /** 126 | * 启动并等待完成 127 | */ 128 | async startAndWait() { 129 | await this.#asyncLock.acquire("start", () => this.#synthesize()); 130 | } 131 | 132 | /** 133 | * 终止捕获 134 | */ 135 | abort() { 136 | if(!this.#abortCallback) 137 | return this.#abortCallback; 138 | this.#abortCallback(); 139 | } 140 | 141 | /** 142 | * 注册字体 143 | * 144 | * @param {Font} font - 字体对象 145 | */ 146 | registerFont(font) { 147 | if (!(font instanceof Font)) 148 | font = new Font(font); 149 | // 开始加载字体 150 | font.load(); 151 | this.fonts.push(font); 152 | } 153 | 154 | /** 155 | * 注册多个字体 156 | * 157 | * @param {Font[]} fonts - 字体对象列表 158 | */ 159 | registerFonts(fonts = []) { 160 | fonts.forEach(font => this.registerFont(font)); 161 | } 162 | 163 | /** 164 | * 合成处理 165 | */ 166 | async #synthesize() { 167 | const page = await this.#acquirePage(); 168 | try { 169 | const { url, content, width, height, pageWaitForOptions, pageViewport = {} } = this; 170 | // 监听页面实例发生的某些内部错误 171 | page.on("error", err => this._emitError("Page error:\n" + err.stack)); 172 | // 监听页面是否崩溃,当内存不足或过载时可能会崩溃 173 | page.on("crashed", err => this.#emitPageCrashedError(err)); 174 | if (this.consoleLog) { 175 | // 监听页面打印到console的正常日志 176 | page.on("consoleLog", message => logger.log("[page]", message)); 177 | // 监听页面打印到console的错误日志 178 | page.on("consoleError", err => logger.error("[page]", err)); 179 | } 180 | if (this.videoPreprocessLog) 181 | page.on("videoPreprocess", config => logger.log("[video_preprocess]", config.url)); 182 | page.on("audioAdd", options => this.addAudio(options)); 183 | page.on("audioUpdate", (audioId, options) => this.updateAudio(audioId, options)) 184 | // 设置视窗宽高 185 | await page.setViewport({ 186 | ...pageViewport, 187 | width, 188 | height 189 | }); 190 | // 跳转到您希望渲染的页面,您可以考虑创建一个本地的Web服务器提供页面以提升加载速度和安全性 191 | if (url) 192 | await page.goto(url, pageWaitForOptions); 193 | // 或者设置页面内容 194 | else 195 | await page.setContent(content, pageWaitForOptions); 196 | // 存在透明通道时设置背景透明度 197 | this.hasAlphaChannel && page.setBackgroundOpacity(this.backgroundOpacity); 198 | // 存在预处理函数时先执行预处理 199 | this.pagePrepareFn && await this.pagePrepareFn(page); 200 | // 注册字体 201 | if (this.fonts.length > 0) 202 | page.registerFonts(this.fonts); 203 | // 等待字体加载完成 204 | await page.waitForFontsLoaded(); 205 | // 注册事件序列 206 | if (this.timeActions && Object.keys(this.timeActions).length > 0) 207 | page.registerTimeActions(this.timeActions); 208 | // 注册终止回调 209 | this.#abortCallback = () => page.target.evaluate(() => captureCtx.abort()).catch(err => console.error(err)); 210 | // 启动合成 211 | super.start(); 212 | // 合成完成promise 213 | const completedPromise = new Promise(resolve => this.once("completed", resolve)); 214 | // 监听已渲染的帧输入到合成器 215 | page.on("frame", buffer => this.input(buffer)); 216 | // 启动捕获 217 | await page.startScreencast({ 218 | fps: this.fps, 219 | startTime: this.startTime, 220 | duration: this.duration, 221 | videoDecoderHardwareAcceleration: this.videoDecoderHardwareAcceleration, 222 | autostart: this.autostartRender 223 | }); 224 | // 监听并等待录制完成 225 | await new Promise(resolve => page.once("screencastCompleted", resolve)); 226 | // 停止录制 227 | await page.stopScreencast(); 228 | // 释放页面资源 229 | await page.release(); 230 | // 告知合成器结束输入 231 | this.endInput(); 232 | // 等待合成完成 233 | await completedPromise; 234 | } 235 | catch (err) { 236 | await page.release(); 237 | this._emitError(err); 238 | } 239 | } 240 | 241 | /** 242 | * 注册页面获取函数 243 | * 244 | * @param {Function} fn 245 | */ 246 | onPageAcquire(fn) { 247 | assert(_.isFunction(fn), "Page acquire function must be Function"); 248 | this.#pageAcquireFn = fn; 249 | } 250 | 251 | /** 252 | * 获取渲染页面 253 | * 254 | * @protected 255 | * @returns {Page} - 页面对象 256 | */ 257 | async #acquirePage() { 258 | assert(_.isFunction(this.#pageAcquireFn), "Page acquire function must be Function"); 259 | return await this.#pageAcquireFn(); 260 | } 261 | 262 | /** 263 | * 发送页面崩溃错误 264 | * 265 | * @param {Error} err - 错误对象 266 | */ 267 | #emitPageCrashedError(err) { 268 | if (this.eventNames().includes("pageCrashed")) 269 | this.emit("pageCrashed", err); 270 | else 271 | logger.error("Page crashed:\n" + err.stack); 272 | } 273 | 274 | } -------------------------------------------------------------------------------- /api/WebVideoCreator.js: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import ffmpeg from "fluent-ffmpeg"; 3 | import _ from "lodash"; 4 | 5 | import globalConfig from "../lib/global-config.js"; 6 | import { VIDEO_ENCODER } from "../lib/const.js"; 7 | import ResourcePool from "../core/ResourcePool.js"; 8 | import SingleVideo from "./SingleVideo.js"; 9 | import ChunkVideo from "./ChunkVideo.js"; 10 | import MultiVideo from "./MultiVideo.js"; 11 | import logger from "../lib/logger.js"; 12 | import cleaner from "../lib/cleaner.js"; 13 | 14 | /** 15 | * @typedef {import('puppeteer-core').WaitForOptions} WaitForOptions 16 | * @typedef {import('puppeteer-core').Viewport} Viewport 17 | */ 18 | 19 | export default class WebVideoCreator { 20 | 21 | /** @type {ResourcePool} - 资源池 */ 22 | pool = null; 23 | /** @type {boolean} - 是否已配置 */ 24 | #configured = false; 25 | 26 | /** 27 | * 配置引擎 28 | * 29 | * @param {Object} config - 配置对象 30 | * @param {string} config.mp4Encoder - 全局MP4格式的视频编码器,默认使用libx264软编码器,建议根据您的硬件选用合适的硬编码器加速合成 31 | * @param {string} config.webmEncoder - 全局WEBM格式的视频编码器,默认使用libvpx软编码器,建议根据您的硬件选用合适的硬编码器加速合成 32 | * @param {string} config.audioEncoder - 全局音频编码器,建议采用默认的aac编码器 33 | * @param {boolean} config.browserUseGPU - 浏览器GPU加速开关,建议开启提高渲染性能,如果您没有GPU设备或遭遇了诡异的渲染问题则可以关闭它 34 | * @param {boolean} config.browserUseAngle - 浏览器是否使用Angle作为渲染后端,建议开启增强渲染跨平台兼容性和性能 35 | * @param {string} config.browserExecutablePath - 浏览器可执行文件路径,设置后将禁用内部的浏览器,建议您默认使用内部的浏览器以确保功能完整性 36 | * @param {number} config.numBrowserMin - 资源池可并行的最小浏览器实例数量 37 | * @param {number} config.numBrowserMax - 资源池可并行的最大浏览器实例数量 38 | * @param {number} config.numPageMin - 浏览器实例可并行的最小页面实例数量 39 | * @param {number} conifg.numPageMax - 浏览器实例可并行的最大页面实例数量 40 | * @param {boolean} config.debug - 开启后将输出一些WVC的调试日志 41 | * @param {boolean} config.browserDebug - 浏览器Debug开关,开启后将输出浏览器的运行日志,如果您想看页面的日志,请设置视频参数的consoleLog为true,而不是这个 42 | * @param {boolean} config.ffmpegDebug - FFmpeg Debug开关,开启后将输出每一条执行的ffmpeg命令 43 | * @param {boolean} config.allowUnsafeContext - 是否允许不安全的上下文,默认禁用,开启后能够导航到不安全的URL,但由于不安全上下文限制,将无法在页面中使用动态图像和内嵌视频 44 | * @param {boolean} config.compatibleRenderingMode - 兼容渲染模式,如果您使用MacOS请开启他,这将导致渲染效率降低40%,启用后将禁用HeadlessExperimental.beginFrame API调用改为普通的Page.screenshot 45 | * @param {string} config.browserVersion - 指定WVC使用的Chrome浏览器版本 46 | * @param {boolean} config.browserHeadless - 浏览器无头开关,建议保持开启,如果关闭请确保开启兼容渲染模式否则无法渲染,仅用于调试画面 47 | * @param {boolean} config.browserFrameRateLimit - 浏览器帧率限制开关,默认开启,关闭帧率限制可以提高渲染效率并支持高于60fps的动画,但这会关闭GPU垂直同步可能导致画面撕裂或其它问题 48 | * @param {string} config.ffmpegExecutablePath - ffmpeg可执行文件路径,设置后将禁用内部的ffmpeg-static,建议您默认使用内部的FFmpeg以确保功能完整性 49 | * @param {string} conifg.ffprobeExecutablePath - ffprobe可执行文件路径,设置后将禁用内部的ffprobe-static,建议您默认使用内部的ffprobe以确保功能完整性 50 | * @param {string} config.frameFormat - 帧图格式(jpeg/png),建议使用jpeg,png捕获较为耗时 51 | * @param {number} config.frameQuality - 捕获帧图质量(0-100),仅frameFormat为jpeg时有效 52 | * @param {number} config.beginFrameTimeout - BeginFrame捕获图像超时时间(毫秒) 53 | * @param {boolean} config.browserDisableDevShm - 是否禁用浏览器使用共享内存,当/dev/shm分区较小时建议开启此选项 54 | * @param {number} config.browserLaunchTimeout - 浏览器启动超时时间(毫秒),设置等待浏览器启动超时时间 55 | * @param {number} config.browserProtocolTimeout - 浏览器协议通信超时时间(毫秒),设置CDP协议通信超时时间 56 | * @param {string} config.userAgent - 访问页面时的用户UA 57 | */ 58 | config(config = {}) { 59 | for (let key in globalConfig) { 60 | if (!_.isUndefined(config[key])) 61 | globalConfig[key] = config[key]; 62 | } 63 | const { ffmpegExecutablePath, ffprobeExecutablePath, browserUseGPU, mp4Encoder } = globalConfig; 64 | // 未启用浏览器GPU发出性能警告 65 | if (!browserUseGPU) 66 | logger.warn("browserUseGPU is turn off, recommended to turn it on to improve rendering performance"); 67 | // 未使用硬编码器发出性能警告 68 | if (Object.values(VIDEO_ENCODER.CPU).includes(mp4Encoder)) 69 | logger.warn(`Recommended to use video hard coder to accelerate video synthesis, currently used is [${globalConfig.mp4Encoder}]`); 70 | // 设置FFmpeg可执行文件路径 71 | ffmpegExecutablePath && ffmpeg.setFfmpegPath(ffmpegExecutablePath); 72 | // 设置FFprobe可执行文件路径 73 | ffprobeExecutablePath && ffmpeg.setFfprobePath(ffprobeExecutablePath); 74 | // 实例化浏览器资源池 75 | this.pool = new ResourcePool(); 76 | // 设置已配置 77 | this.#configured = true; 78 | } 79 | 80 | /** 81 | * 创建单幕视频 82 | * 83 | * @param {Object} options - 单幕视频选项 84 | * @param {string} [options.url] - 页面URL 85 | * @param {string} [options.content] - 页面内容 86 | * @param {string} options.outputPath - 输出路径 87 | * @param {number} options.width - 视频宽度 88 | * @param {number} options.height - 视频高度 89 | * @param {number} options.duration - 视频时长 90 | * @param {number} [options.startTime=0] - 开始捕获时间点 91 | * @param {number} [options.fps=30] - 视频帧率 92 | * @param {string} [options.format] - 导出视频格式(mp4/webm) 93 | * @param {string} [options.attachCoverPath] - 附加到视频首帧的封面路径 94 | * @param {string} [options.coverCapture=false] - 是否捕获封面并输出 95 | * @param {number} [options.coverCaptureTime] - 封面捕获时间点(毫秒) 96 | * @param {string} [options.coverCaptureFormat="jpg"] - 封面捕获格式(jpg/png/bmp) 97 | * @param {string} [options.videoEncoder="libx264"] - 视频编码器 98 | * @param {number} [options.videoQuality=100] - 视频质量(0-100) 99 | * @param {string} [options.videoBitrate] - 视频码率(设置码率将忽略videoQuality) 100 | * @param {string} [options.pixelFormat="yuv420p"] - 像素格式(yuv420p/yuv444p/rgb24) 101 | * @param {string} [options.audioEncoder="aac"] - 音频编码器 102 | * @param {string} [options.audioBitrate] - 音频码率 103 | * @param {number} [options.volume] - 视频音量(0-100) 104 | * @param {number} [options.parallelWriteFrames=10] - 并行写入帧数 105 | * @param {boolean} [options.showProgress=false] - 是否在命令行展示进度 106 | * @param {boolean} [options.backgroundOpacity=1] - 背景不透明度(0-1),仅webm格式支持 107 | * @param {boolean} [options.autostartRender=true] - 是否自动启动渲染,如果为false请务必在页面中执行 captureCtx.start() 108 | * @param {boolean} [options.consoleLog=false] - 是否开启控制台日志输出 109 | * @param {boolean} [options.videoPreprocessLog=false] - 是否开启视频预处理日志输出 110 | * @param {string} [options.videoDecoderHardwareAcceleration] - VideoDecoder硬件加速指示 111 | * @param {WaitForOptions} [options.pageWaitForOptions] - 页面等待选项 112 | * @param {Viewport} [options.pageViewport] - 页面视窗参数 113 | * @param {Function} [options.pagePrepareFn] - 页面预处理函数 114 | * @param {{[key: number]: Function}} [options.timeActions] - 动作序列 115 | */ 116 | createSingleVideo(options) { 117 | assert(this.#configured, "WebVideoCreator has not been configured yet, please execute config() first"); 118 | const singleVideo = new SingleVideo(options); 119 | // 注册获取页面函数 120 | singleVideo.onPageAcquire(async () => await this.pool.acquirePage()); 121 | return singleVideo; 122 | } 123 | 124 | /** 125 | * 创建多幕视频 126 | * 127 | * @param {Object} options - 序列帧合成器选项 128 | * @param {string} options.outputPath - 导出视频路径 129 | * @param {number} options.width - 视频宽度 130 | * @param {number} options.height - 视频高度 131 | * @param {number} options.duration - 视频时长 132 | * @param {ChunkVideo[]} options.chunks - 分块视频列表 133 | * @param {number} [options.fps=30] - 视频合成帧率 134 | * @param {string} [options.format] - 导出视频格式(mp4/webm) 135 | * @param {string} [options.attachCoverPath] - 附加到视频首帧的封面路径 136 | * @param {string} [options.coverCapture=false] - 是否捕获封面并输出 137 | * @param {number} [options.coverCaptureTime] - 封面捕获时间点(毫秒) 138 | * @param {string} [options.coverCaptureFormat="jpg"] - 封面捕获格式(jpg/png/bmp) 139 | * @param {string} [options.videoEncoder="libx264"] - 视频编码器 140 | * @param {number} [options.videoQuality=100] - 视频质量(0-100) 141 | * @param {string} [options.videoBitrate] - 视频码率(设置码率将忽略videoQuality) 142 | * @param {string} [options.pixelFormat="yuv420p"] - 像素格式(yuv420p/yuv444p/rgb24) 143 | * @param {string} [options.audioEncoder="aac"] - 音频编码器 144 | * @param {string} [options.audioBitrate] - 音频码率 145 | * @param {number} [options.volume] - 视频音量(0-100) 146 | * @param {number} [options.parallelWriteFrames=10] - 并行写入帧数 147 | * @param {boolean} [options.showProgress=false] - 是否在命令行展示进度 148 | * @param {Function} [options.pagePrepareFn] - 页面预处理函数 149 | */ 150 | createMultiVideo(options) { 151 | assert(this.#configured, "WebVideoCreator has not been configured yet, please execute config() first"); 152 | const multiVideo = new MultiVideo(options); 153 | // 注册获取页面函数 154 | multiVideo.onPageAcquire(async () => await this.pool.acquirePage()) 155 | return multiVideo; 156 | } 157 | 158 | /** 159 | * 创建分块视频 160 | * 161 | * @param {Object} options - 分块视频选项 162 | * @param {string} [options.url] - 页面URL 163 | * @param {string} [options.content] - 页面内容 164 | * @param {string} options.outputPath - 输出路径 165 | * @param {number} options.width - 视频宽度 166 | * @param {number} options.height - 视频高度 167 | * @param {number} options.duration - 视频时长 168 | * @param {number} [options.startTime=0] - 开始捕获时间点 169 | * @param {number} [options.fps=30] - 视频帧率 170 | * @param {Transition} [options.transition] - 进入下一视频分块的转场 171 | * @param {string} [options.format] - 导出视频格式(mp4/webm) 172 | * @param {string} [options.attachCoverPath] - 附加到视频首帧的封面路径 173 | * @param {string} [options.coverCapture=false] - 是否捕获封面并输出 174 | * @param {number} [options.coverCaptureTime] - 封面捕获时间点(毫秒) 175 | * @param {string} [options.coverCaptureFormat="jpg"] - 封面捕获格式(jpg/png/bmp) 176 | * @param {string} [options.videoEncoder="libx264"] - 视频编码器 177 | * @param {number} [options.videoQuality=100] - 视频质量(0-100) 178 | * @param {string} [options.videoBitrate] - 视频码率(设置码率将忽略videoQuality) 179 | * @param {string} [options.pixelFormat="yuv420p"] - 像素格式(yuv420p/yuv444p/rgb24) 180 | * @param {string} [options.audioEncoder="aac"] - 音频编码器 181 | * @param {string} [options.audioBitrate] - 音频码率 182 | * @param {number} [options.volume] - 视频音量(0-100) 183 | * @param {number} [options.parallelWriteFrames=10] - 并行写入帧数 184 | * @param {boolean} [options.showProgress=false] - 是否在命令行展示进度 185 | * @param {boolean} [options.backgroundOpacity=1] - 背景不透明度(0-1),仅webm格式支持 186 | * @param {boolean} [options.autostartRender=true] - 是否自动启动渲染,如果为false请务必在页面中执行 captureCtx.start() 187 | * @param {boolean} [options.consoleLog=false] - 是否开启控制台日志输出 188 | * @param {boolean} [options.videoPreprocessLog=false] - 是否开启视频预处理日志输出 189 | * @param {string} [options.videoDecoderHardwareAcceleration] - VideoDecoder硬件加速指示 190 | * @param {WaitForOptions} [options.pageWaitForOptions] - 页面等待选项 191 | * @param {Viewport} [options.pageViewport] - 页面视窗参数 192 | * @param {Function} [options.pagePrepareFn] - 页面预处理函数 193 | * @param {{[key: number]: Function}} [options.timeActions] - 动作序列 194 | */ 195 | createChunkVideo(options) { 196 | assert(this.#configured, "WebVideoCreator has not been configured yet, please execute config() first"); 197 | const chunkVideo = new ChunkVideo(options); 198 | // 注册获取页面函数 199 | chunkVideo.onPageAcquire(async () => await this.pool.acquirePage()); 200 | return chunkVideo; 201 | } 202 | 203 | /** 清理浏览器缓存 */ 204 | cleanBrowserCache = cleaner.cleanBrowserCache.bind(cleaner); 205 | 206 | /** 清理预处理缓存 */ 207 | cleanPreprocessCache = cleaner.cleanPreprocessCache.bind(cleaner); 208 | 209 | /** 清理合成缓存 */ 210 | cleanSynthesizeCache = cleaner.cleanSynthesizeCache.bind(cleaner); 211 | 212 | /** 清理本地字体缓存 */ 213 | cleanLocalFontCache = cleaner.cleanLocalFontCache.bind(cleaner); 214 | 215 | } -------------------------------------------------------------------------------- /api/index.js: -------------------------------------------------------------------------------- 1 | import WebVideoCreator from "./WebVideoCreator.js"; 2 | import SingleVideo from "./SingleVideo.js"; 3 | import MultiVideo from "./MultiVideo.js"; 4 | import ChunkVideo from "./ChunkVideo.js"; 5 | 6 | export default WebVideoCreator; 7 | export { 8 | SingleVideo, 9 | MultiVideo, 10 | ChunkVideo 11 | }; -------------------------------------------------------------------------------- /assets/chunk-video.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinlic/WebVideoCreator/fb62279513ca0ac01f6b8864a1e43101cbc9532e/assets/chunk-video.gif -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinlic/WebVideoCreator/fb62279513ca0ac01f6b8864a1e43101cbc9532e/assets/demo.gif -------------------------------------------------------------------------------- /assets/multi-video.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinlic/WebVideoCreator/fb62279513ca0ac01f6b8864a1e43101cbc9532e/assets/multi-video.gif -------------------------------------------------------------------------------- /assets/single-video.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinlic/WebVideoCreator/fb62279513ca0ac01f6b8864a1e43101cbc9532e/assets/single-video.gif -------------------------------------------------------------------------------- /assets/web-video-creator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinlic/WebVideoCreator/fb62279513ca0ac01f6b8864a1e43101cbc9532e/assets/web-video-creator.png -------------------------------------------------------------------------------- /core/ChunkSynthesizer.js: -------------------------------------------------------------------------------- 1 | import "colors"; 2 | import assert from "assert"; 3 | import fs from "fs-extra"; 4 | import ffmpeg from "fluent-ffmpeg"; 5 | import cliProgress from "cli-progress"; 6 | import _ from "lodash"; 7 | 8 | import Synthesizer from "./Synthesizer.js"; 9 | import VideoChunk from "./VideoChunk.js"; 10 | import Transition from "../entity/Transition.js"; 11 | import logger from "../lib/logger.js"; 12 | 13 | /** 14 | * 视频分块合成器 15 | */ 16 | export default class ChunkSynthesizer extends Synthesizer { 17 | 18 | /** @type {VideoChunk[]} - 视频块列表 */ 19 | chunks = []; 20 | 21 | /** 22 | * 构造函数 23 | * 24 | * @param {Object} options - 视频分块合成器选项 25 | * @param {string} options.outputPath - 导出视频路径 26 | * @param {number} options.width - 视频宽度 27 | * @param {number} options.height - 视频高度 28 | * @param {VideoChunk[]} options.chunks - 视频分块列表 29 | * @param {number} [options.fps=30] - 视频合成帧率 30 | * @param {string} [options.format] - 导出视频格式(mp4/webm) 31 | * @param {string} [options.attachCoverPath] - 附加到视频首帧的封面路径 32 | * @param {string} [options.coverCapture=false] - 是否捕获封面并输出 33 | * @param {number} [options.coverCaptureTime] - 封面捕获时间点(毫秒) 34 | * @param {string} [options.coverCaptureFormat="jpg"] - 封面捕获格式(jpg/png/bmp) 35 | * @param {string} [options.videoEncoder="libx264"] - 视频编码器 36 | * @param {number} [options.videoQuality=100] - 视频质量(0-100) 37 | * @param {string} [options.videoBitrate] - 视频码率(设置码率将忽略videoQuality) 38 | * @param {string} [options.pixelFormat="yuv420p"] - 像素格式(yuv420p/yuv444p/rgb24) 39 | * @param {string} [options.audioEncoder="aac"] - 音频编码器 40 | * @param {string} [options.audioBitrate] - 音频码率 41 | * @param {number} [options.volume] - 视频音量(0-100) 42 | * @param {number} [options.parallelWriteFrames=10] - 并行写入帧数 43 | * @param {boolean} [options.showProgress=false] - 是否在命令行展示进度 44 | */ 45 | constructor(options) { 46 | assert(_.isObject(options), "ChunkSynthesizer options must be object"); 47 | options.duration = 0; 48 | super(options); 49 | const { chunks } = options; 50 | assert(_.isUndefined(chunks) || _.isArray(chunks), "chunks must be VideoChunk[]"); 51 | if (this.showProgress) { 52 | this._cliProgress = new cliProgress.MultiBar({ 53 | hideCursor: true, 54 | format: `[${"{bar}".cyan}] {percentage}% | {value}/{total} | {eta_formatted} | {filename}`, 55 | }, cliProgress.Presets.shades_grey); 56 | } 57 | chunks && chunks.forEach(chunk => this.input(chunk)); 58 | } 59 | 60 | /** 61 | * 输入视频分块 62 | * 63 | * @param {VideoChunk} chunk - 视频分块 64 | * @param {Transition} [transition] - 进入下一分块的转场对象 65 | */ 66 | input(chunk, transition) { 67 | _.isFinite(this.width) && (chunk.width = _.defaultTo(chunk.width, this.width)); 68 | _.isFinite(this.height) && (chunk.height = _.defaultTo(chunk.height, this.height)); 69 | _.isFinite(this.fps) && (chunk.fps = _.defaultTo(chunk.fps, this.fps)); 70 | if (!(chunk instanceof VideoChunk)) 71 | chunk = new VideoChunk(chunk); 72 | assert(chunk.width == this.width, "input chunk width does not match the previous block"); 73 | assert(chunk.height == this.height, "input chunk height does not match the previous block"); 74 | assert(chunk.fps == this.fps, "input chunk fps does not match the previous block"); 75 | transition && chunk.setTransition(_.isString(transition) ? { id: transition } : transition); 76 | if (this.showProgress) 77 | chunk.attachCliProgress(this._cliProgress); 78 | this.chunks.push(chunk); 79 | this.width = chunk.width; 80 | this.height = chunk.height; 81 | this.fps = chunk.fps; 82 | this.duration += chunk.getOutputDuration(); 83 | this._targetFrameCount += chunk.targetFrameCount; 84 | } 85 | 86 | /** 87 | * 屏蔽结束输入 88 | */ 89 | endInput() {} 90 | 91 | /** 92 | * 启动合成 93 | */ 94 | start() { 95 | assert(this.chunks.length > 0, "There is no VideoChunk that can be synthesized"); 96 | this._startupTime = performance.now(); 97 | let offsetTime = 0 98 | const chunksRenderPromises = []; 99 | this.chunks.forEach(chunk => { 100 | chunk.audios.forEach(audio => { 101 | if (!_.isFinite(audio.startTime)) 102 | audio.startTime = 0; 103 | audio.startTime += offsetTime; 104 | if (!_.isFinite(audio.endTime)) 105 | audio.endTime = chunk.duration; 106 | audio.endTime += offsetTime; 107 | this.addAudio(audio); 108 | }); 109 | // 分块未完成时先进行渲染 110 | !chunk.isCompleted() && chunksRenderPromises.push(this.renderChunk(chunk, offsetTime)); 111 | offsetTime += chunk.getOutputDuration(); 112 | }); 113 | // 等待分块渲染完成再开始合成流程 114 | Promise.all(chunksRenderPromises) 115 | .then(() => super.start()) 116 | .catch(err => this._emitError(err)); 117 | } 118 | 119 | /** 120 | * 渲染分块 121 | * 122 | * @param {VideoChunk} chunk - 视频分块 123 | * @param {number} offsetTime - 分块偏移时间点 124 | */ 125 | async renderChunk(chunk, offsetTime) { 126 | if (chunk.isCompleted()) 127 | return; 128 | return await new Promise((resolve, reject) => { 129 | chunk.on("audioAdd", options => { 130 | const audio = this.addAudio(options); 131 | if (!_.isFinite(audio.startTime)) 132 | audio.startTime = 0; 133 | audio.startTime += offsetTime; 134 | if (!_.isFinite(audio.endTime)) 135 | audio.endTime = chunk.duration; 136 | audio.endTime += offsetTime; 137 | }); 138 | chunk.on("audioUpdate", options => { 139 | if (_.isFinite(options.startTime)) 140 | options.startTime += offsetTime; 141 | if (_.isFinite(options.endTime)) 142 | options.endTime += offsetTime; 143 | this.updateAudio(options); 144 | }); 145 | chunk.on("progress", () => this._emitChunksProgress()); 146 | chunk.once("completed", resolve); 147 | chunk.once("error", reject); 148 | chunk.isReady() && chunk.start(); 149 | }); 150 | } 151 | 152 | /** 153 | * 发送进度事件 154 | */ 155 | _emitChunksProgress() { 156 | const { progress: totalProgress, frameCount: totalFrameCount } = this.chunks.reduce((total, chunk) => { 157 | total.progress += chunk.progress; 158 | total.frameCount += chunk.frameCount; 159 | return total; 160 | }, { 161 | progress: 0, 162 | frameCount: 0 163 | }); 164 | this.progress = Math.floor(totalProgress / this.chunks.length * 0.95 * 1000) / 1000; 165 | this._frameCount = totalFrameCount; 166 | this.emit("progress", this.progress * 0.95, totalFrameCount, this._targetFrameCount); 167 | } 168 | 169 | /** 170 | * 发送进度事件 171 | * 172 | * @protected 173 | * @param {number} value - 进度值 174 | */ 175 | _emitProgress(value, frameCount, targetFrameCount) { 176 | if (value < 0) 177 | return; 178 | let progress = this.progress + Math.floor(value * 0.05 * 1000) / 1000; 179 | if (progress > 100) 180 | progress = 100; 181 | if (this.showProgress) { 182 | if(this._cliProgress instanceof cliProgress.MultiBar) { 183 | this._cliProgress.stop(); 184 | this._cliProgress = new cliProgress.SingleBar({ 185 | hideCursor: true, 186 | format: `[${"{bar}".green}] {percentage}% | {value}/{total} | {eta_formatted} | {filename}`, 187 | }, cliProgress.Presets.shades_grey); 188 | } 189 | if (!this._cliProgress.started) { 190 | logger.log(`Waiting to merge ${this.chunks.length} chunks and audio synthesis...`); 191 | this._cliProgress.start(targetFrameCount, 0); 192 | this._cliProgress.started = true; 193 | } 194 | this._cliProgress.update(frameCount, { filename: this.name }); 195 | } 196 | this.emit("progress", progress, this._frameCount, this._targetFrameCount); 197 | } 198 | 199 | /** 200 | * 发送已完成事件 201 | * 202 | * @protected 203 | */ 204 | _emitCompleted() { 205 | Promise.all(this.chunks.map(chunk => chunk.autoremove && fs.remove(chunk.outputPath))) 206 | .catch(err => logger.error(err)); 207 | super._emitCompleted(); 208 | } 209 | 210 | /** 211 | * 创建视频编码器 212 | * 213 | * @protected 214 | * @returns {FfmpegCommand} - 编码器 215 | */ 216 | _createVideoEncoder() { 217 | const { chunks, width, height, _swapFilePath, format, 218 | videoEncoder, videoBitrate, videoQuality, pixelFormat, attachCoverPath } = this; 219 | const vencoder = ffmpeg(); 220 | // 设置视频码率将忽略质量设置 221 | if (videoBitrate) 222 | vencoder.videoBitrate(videoBitrate); 223 | else { 224 | // 计算总像素量 225 | const pixels = width * height; 226 | // 根据像素总量设置视频码率 227 | vencoder.videoBitrate(`${(2560 / 921600 * pixels) * (videoQuality / 100)}k`); 228 | } 229 | // 输入命令集合 230 | const inputs = []; 231 | // 复合过滤器 232 | let complexFilter = ''; 233 | // 时长偏移 234 | let durationOffset = 0; 235 | // 上一个输出索引 236 | let lastOutput = null; 237 | for (let i = 0; i < chunks.length; i++) { 238 | // 当前分块 239 | const chunk = chunks[i]; 240 | // 获取上一个分块 241 | const lastChunk = i > 0 ? chunks[i - 1] : null; 242 | // 如果存在上一分块则处理转场 243 | if (lastChunk) { 244 | // 当前输入索引 245 | const index = inputs.length ? inputs.length - 1 : 0; 246 | // 如果上一分块存在转场则填充输入和过滤器 247 | if (lastChunk.transition) { 248 | // 将此分块路径添加到输入 249 | inputs.push(chunk.outputPath); 250 | // 如果存在上层输出则使用否则以当前块作为输入 251 | const input = lastOutput || `[${index}:v]`; 252 | // 输出索引 253 | const output = `[v${index}]`; 254 | // 获取上一分块转场参数 255 | let { id: transtiionId, duration: transitionDuration } = lastChunk.transition; 256 | // 上一分块时长减去当前转场时长获得偏移量 257 | durationOffset += (lastChunk.duration - transitionDuration); 258 | // 添加转场到复合过滤器 259 | complexFilter += `${input}[${index + 1}:v]xfade=transition=${transtiionId}:duration=${Math.floor(transitionDuration / 1000 * 100) / 100}:offset=${Math.floor(durationOffset / 1000 * 100) / 100}${output};`; 260 | // 设置当前输出索引用于下次处理 261 | lastOutput = output; 262 | } 263 | // 如果没有转场则直接拼接加快合成速度 264 | else { 265 | // 偏移上一分块时长 266 | durationOffset += lastChunk.duration; 267 | // 如果最后一个输入不存在或者输入非拼接态将处理为拼接 268 | if (!inputs[index] || inputs[index].indexOf("concat") !== 0) 269 | inputs[index] = `concat:${lastChunk.outputPath}|${chunk.outputPath}`; 270 | else 271 | inputs[index] += `|${chunk.outputPath}`; //拼到拼接态字符串尾部 272 | } 273 | } 274 | // 不存在上一分块直接作为输入 275 | else 276 | inputs.push(chunk.outputPath); 277 | } 278 | // 将所有分块输出路径输入 279 | inputs.forEach(input => vencoder.addInput(input)); 280 | // 获取任务封面路径 281 | if (attachCoverPath) { 282 | vencoder.addInput(attachCoverPath); 283 | const output = `[v${inputs.length}]`; 284 | complexFilter += `[${inputs.length}:v]scale=${width}:${height}[cover];${lastOutput || "[0:v]"}[cover]overlay=repeatlast=0${output};`; 285 | inputs.push(attachCoverPath); 286 | lastOutput = output; 287 | } 288 | // 如采用复合过滤器将应用 289 | if (complexFilter) { 290 | vencoder.complexFilter(complexFilter.replace(`${lastOutput};`, `,format=${pixelFormat}[output]`)); 291 | vencoder.outputOption("-map [output]"); 292 | } 293 | // 获取编码类型 294 | const encodingType = this.getVideoEncodingType(); 295 | if (encodingType == "H264" || encodingType == "H265") { 296 | // 使用主要配置 297 | vencoder.outputOption("-profile:v main"); 298 | // 使用中等预设 299 | vencoder.outputOption("-preset medium"); 300 | } 301 | vencoder 302 | // 指定视频编码器 303 | .videoCodec(videoEncoder) 304 | // 移动MOOV头到前面 305 | .outputOption("-movflags +faststart") 306 | // 指定输出格式 307 | .toFormat(format) 308 | .addOutput(_swapFilePath); 309 | return vencoder; 310 | } 311 | 312 | /** 313 | * 获取已合成视频时长 314 | * 315 | * @returns {number} - 已合成视频时长 316 | */ 317 | getOutputDuration() { 318 | return this.duration; 319 | } 320 | 321 | } -------------------------------------------------------------------------------- /core/ResourcePool.js: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import AsyncLock from "async-lock"; 3 | import genericPool, { Pool as _Pool } from "generic-pool"; 4 | import VideoPreprocessor from "../preprocessor/video/VideoPreprocessor.js"; 5 | import _ from "lodash"; 6 | 7 | import Browser from "./Browser.js"; 8 | import Page from "./Page.js"; 9 | import globalConfig from "../lib/global-config.js"; 10 | import logger from "../lib/logger.js"; 11 | 12 | // 异步锁 13 | const asyncLock = new AsyncLock(); 14 | 15 | /** 16 | * 资源池 17 | */ 18 | export default class ResourcePool { 19 | 20 | /** 21 | * @typedef {Object} PageOptions 22 | * @property {number} [width] - 页面视窗宽度 23 | * @property {number} [height] - 页面视窗高度 24 | * @property {string} [userAgent] - 用户UA 25 | * @property {number} [beginFrameTimeout=5000] - BeginFrame超时时间(毫秒) 26 | * @property {string} [frameFormat="jpeg"] - 帧图格式 27 | * @property {number} [frameQuality=80] - 帧图质量(0-100) 28 | */ 29 | 30 | /** 31 | * @typedef {Object} BrowserOptions 32 | * @property {number} numPageMax - 页面资源最大数量 33 | * @property {number} numPageMin - 页面资源最小数量 34 | * @property {string} [executablePath] - 浏览器可执行文件路径 35 | * @property {boolean} [useGPU=true] - 是否使用GPU加速渲染 36 | * @property {boolean} [useAngle=true] - 3D渲染后端是否使用Angle,建议开启 37 | * @property {boolean} [disableDevShm=false] - 是否禁用共享内存,当/dev/shm较小时建议开启此选项 38 | * @property {string[]} [args] - 浏览器启动参数 39 | * @property {boolean} [debug=false] - 浏览器日志是否输出到控制台 40 | * @property {PageOptions} [pageOptions] - 页面选项 41 | */ 42 | 43 | /** 44 | * @typedef {Object} VideoPreprocessorOptions 45 | * @property {number} [parallelDownloads=10] - 并行下载数量 46 | * @property {number} [parallelProcess=10] - 并行处理数量 47 | * @property {string} [videoEncoder="libx264"] - 视频编码器(必须为H264编码器) 48 | */ 49 | 50 | /** @type {_Pool} - 浏览器资源池 */ 51 | #browserPool; 52 | /** @type {VideoPreprocessor} - 视频预处理器 */ 53 | #videoPreprocessor; 54 | /** @type {number} - 浏览器资源最大数量 */ 55 | numBrowserMax; 56 | /** @type {number} - 浏览器资源最小数量 */ 57 | numBrowserMin; 58 | /** @type {BrowserOptions} - 浏览器选项 */ 59 | browserOptions = {}; 60 | /** @type {VideoPreprocessorOptions} - 浏览器选项 */ 61 | videoPreprocessorOptions = {}; 62 | #warmupped = false; 63 | #checkMap = {}; 64 | 65 | /** 66 | * 构造函数 67 | * 68 | * @param {Object} options - 资源池选项 69 | * @param {number} [options.numBrowserMax=5] - 浏览器资源最大数量 70 | * @param {number} [options.numBrowserMin=1] - 浏览器资源最小数量 71 | * @param {BrowserOptions} [options.browserOptions={}] - 浏览器选项 72 | * @param {VideoPreprocessorOptions} [options.videoPreprocessorOptions={}] - 视频预处理器选项 73 | */ 74 | constructor(options = {}) { 75 | assert(_.isObject(options), "ResourcePool options must provided"); 76 | const { numBrowserMax, numBrowserMin, browserOptions, videoPreprocessorOptions } = options; 77 | assert(_.isUndefined(numBrowserMax) || _.isFinite(numBrowserMax), "ResourcePool options.numBrowserMax must be number"); 78 | assert(_.isUndefined(numBrowserMin) || _.isFinite(numBrowserMin), "ResourcePool options.numBrowserMin must be number"); 79 | assert(_.isUndefined(browserOptions) || _.isObject(browserOptions), "ResourcePool options.browserOptions must be object"); 80 | assert(_.isUndefined(videoPreprocessorOptions) || _.isObject(videoPreprocessorOptions), "ResourcePool options.browserOptions must be object"); 81 | this.numBrowserMax = _.defaultTo(numBrowserMax, _.defaultTo(globalConfig.numBrowserMax, 5)); 82 | this.numBrowserMin = _.defaultTo(numBrowserMin, _.defaultTo(globalConfig.numBrowserMin, 1)); 83 | this.browserOptions = _.defaultTo(browserOptions, {}); 84 | this.videoPreprocessorOptions = _.defaultTo(videoPreprocessorOptions, {}); 85 | this.#videoPreprocessor = new VideoPreprocessor(this.videoPreprocessorOptions); 86 | this.#createBrowserPool(); 87 | this.#checker(); 88 | } 89 | 90 | /** 91 | * 预热浏览器资源池 92 | */ 93 | async warmup() { 94 | if(this.#warmupped) return; 95 | await asyncLock.acquire("warmup", async () => { 96 | this.#browserPool.start(); 97 | await this.#browserPool.ready(); 98 | this.#warmupped = true; 99 | }); 100 | } 101 | 102 | /** 103 | * 创建浏览器资源池 104 | */ 105 | #createBrowserPool() { 106 | this.#browserPool = genericPool.createPool({ 107 | create: this.#createBrowser.bind(this), 108 | destroy: async target => target.close(), 109 | validate: target => target.isReady() 110 | }, { 111 | max: this.numBrowserMax, 112 | min: this.numBrowserMin, 113 | autostart: false 114 | }); 115 | this.#browserPool.on('factoryCreateError', (error) => { 116 | const client = this.#browserPool._waitingClientsQueue.dequeue(); 117 | if(!client) return logger.error(error); 118 | client.reject(error); 119 | }); 120 | } 121 | 122 | /** 123 | * 获取可用页面资源 124 | * 125 | * @returns {Page} 126 | */ 127 | async acquirePage() { 128 | // 使用异步锁解决重入 129 | return await asyncLock.acquire("acquirePage", async () => { 130 | // 获取可用的浏览器资源 131 | const browser = await this.acquireBrowser(); 132 | // 从浏览器获取可用的页面资源 133 | const page = await browser.acquirePage(); 134 | // 如果浏览器页面池未饱和则释放浏览器资源供下一次获取 135 | if (!browser.isBusy()) 136 | await browser.release(); 137 | // 如果已饱和加入检查列表等待未饱和时释放浏览器资源 138 | else if (!this.#checkMap[browser.id]) { 139 | this.#checkMap[browser.id] = () => { 140 | if (!browser.isBusy()) { 141 | browser.release(); 142 | return true; 143 | } 144 | return false; 145 | }; 146 | } 147 | // 返回可用页面资源 148 | return page; 149 | }); 150 | } 151 | 152 | /** 153 | * 获取可用浏览器资源 154 | * 155 | * @returns {Browser} 156 | */ 157 | async acquireBrowser() { 158 | !this.#warmupped && await this.warmup(); 159 | return await this.#browserPool.acquire(); 160 | } 161 | 162 | /** 163 | * 创建浏览器资源 164 | * 165 | * @returns {Browser} - 浏览器资源 166 | */ 167 | async #createBrowser() { 168 | const browser = new Browser(this, this.browserOptions); 169 | await browser.init(); 170 | return browser; 171 | } 172 | 173 | /** 174 | * 释放浏览器资源 175 | * 176 | * @param {Browser} browser - 浏览器资源 177 | */ 178 | async releaseBrowser(browser) { 179 | await this.#browserPool.release(browser); 180 | } 181 | 182 | /** 183 | * 销毁浏览器资源 184 | * 185 | * @param {Browser} browser - 浏览器资源 186 | */ 187 | async destoryBrowser(browser) { 188 | if (this.#checkMap[browser.id]) 189 | delete this.#checkMap[browser.id]; 190 | await this.#browserPool.destroy(browser); 191 | } 192 | 193 | /** 194 | * 判断浏览器资源池是否饱和 195 | * 196 | * @returns {boolean} 浏览器池是否饱和 197 | */ 198 | isBusy() { 199 | return this.#browserPool.borrowed >= this.#browserPool.max; 200 | } 201 | 202 | /** 203 | * 检查器 204 | */ 205 | #checker() { 206 | (async () => { 207 | for (let id in this.#checkMap) { 208 | if (this.#checkMap[id]()) 209 | delete this.#checkMap[id]; 210 | } 211 | })() 212 | .then(() => setTimeout(this.#checker.bind(this), 5000)) 213 | .catch(err => logger.error(err)); 214 | } 215 | 216 | /** 217 | * 获取视频预处理器 218 | */ 219 | get videoPreprocessor() { 220 | return this.#videoPreprocessor; 221 | } 222 | 223 | } -------------------------------------------------------------------------------- /core/VideoChunk.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import assert from "assert"; 3 | import uniqid from "uniqid"; 4 | import _ from "lodash"; 5 | 6 | import Synthesizer from "./Synthesizer.js"; 7 | import { BITSTREAM_FILTER } from "../lib/const.js"; 8 | import Transition from "../entity/Transition.js"; 9 | import Audio from "../entity/Audio.js"; 10 | import util from "../lib/util.js"; 11 | 12 | /** 13 | * 视频分块 14 | */ 15 | export default class VideoChunk extends Synthesizer { 16 | 17 | /** @type {Transition} - 进入下一视频分块的转场 */ 18 | transition; 19 | /** @type {boolean} - 被合并后是否自动删除分块文件 */ 20 | autoremove; 21 | 22 | /** 23 | * 构造函数 24 | * 25 | * @param {Object} options - 分块合成器选项 26 | * @param {number} options.width - 视频宽度 27 | * @param {number} options.height - 视频高度 28 | * @param {number} options.fps - 视频合成帧率 29 | * @param {number} options.duration - 视频时长 30 | * @param {string} [options.outputPath] - 导出视频分块路径 31 | * @param {string|Transition} [options.transition] - 进入下一视频分块的转场 32 | * @param {boolean} [options.autoremove=true] - 分块被合并后是否自动删除分块文件 33 | * @param {string} [options.videoEncoder] - 视频编码器 34 | * @param {number} [options.videoQuality] - 视频质量(0-100) 35 | * @param {string} [options.videoBitrate] - 视频码率(设置码率将忽略videoQuality) 36 | * @param {string} [options.pixelFormat] - 像素格式(yuv420p/yuv444p/rgb24) 37 | * @param {number} [options.parallelWriteFrames=10] - 并行写入帧数 38 | * @param {boolean} [options.showProgress=false] - 是否在命令行展示进度 39 | */ 40 | constructor(options = {}) { 41 | super(options); 42 | const { transition, autoremove } = options; 43 | this.outputPath = _.defaultTo(this.outputPath, path.join(this.tmpDirPath, `${uniqid("chunk_")}.ts`)); 44 | this.name = _.defaultTo(this.name, path.basename(this.outputPath)); 45 | assert(util.getPathExtname(this.outputPath) == "ts", "Video chunk output path extname must be .ts"); 46 | transition && this.setTransition(transition); 47 | this.autoremove = _.defaultTo(autoremove, true); 48 | this.coverCapture = false; 49 | this.format = "mpegts"; 50 | const encodingType = this.getVideoEncodingType(); 51 | assert(_.isString(BITSTREAM_FILTER[encodingType]), `Video encoder ${this.videoEncoder} does not support use in VideoChunk, only support encoding using H264, H265, and VP9`) 52 | } 53 | 54 | /** 55 | * 添加音频 56 | * 57 | * @param {Audio} audio - 音频对象 58 | */ 59 | addAudio(audio) { 60 | if (!(audio instanceof Audio)) 61 | audio = new Audio(audio); 62 | this.audios.push(audio); 63 | return audio; 64 | } 65 | 66 | /** 67 | * 设置合成下一视频分块时的转场 68 | * 69 | * @param {Transition} transition - 转场对象 70 | */ 71 | setTransition(transition) { 72 | if (_.isString(transition)) 73 | transition = new Transition({ id: transition }); 74 | else if (!(transition instanceof Transition)) 75 | transition = new Transition(transition); 76 | this.transition = transition; 77 | } 78 | 79 | /** 80 | * 获取已合成视频时长 81 | * 82 | * @returns {number} - 已合成视频时长 83 | */ 84 | getOutputDuration() { 85 | return super.getOutputDuration() - this.transitionDuration; 86 | } 87 | 88 | /** 89 | * 创建视频编码器 90 | * 91 | * @protected 92 | * @returns {FfmpegCommand} - 编码器 93 | */ 94 | _createVideoEncoder() { 95 | const encodingType = this.getVideoEncodingType(); 96 | const bitstreamFilter = BITSTREAM_FILTER[encodingType]; 97 | const vencoder = super._createVideoEncoder(); 98 | vencoder.outputOption(`-bsf:v ${bitstreamFilter}`) 99 | return vencoder; 100 | } 101 | 102 | /** 103 | * 判断是否VideoChunk 104 | * 105 | * @protected 106 | * @returns {boolean} - 是否为VideoChunk 107 | */ 108 | _isVideoChunk() { 109 | return true; 110 | } 111 | 112 | /** 113 | * 获取转场ID 114 | */ 115 | get transitionId() { 116 | return this.transition ? this.transition.id : 0; 117 | } 118 | 119 | /** 120 | * 获取转场时长 121 | */ 122 | get transitionDuration() { 123 | return this.transition ? this.transition.duration : 0; 124 | } 125 | 126 | } -------------------------------------------------------------------------------- /core/index.js: -------------------------------------------------------------------------------- 1 | import CaptureContext from "./CaptureContext.js"; 2 | import ResourcePool from "./ResourcePool.js"; 3 | import Browser from "./Browser.js"; 4 | import Page from "./Page.js"; 5 | import ChunkSynthesizer from "./ChunkSynthesizer.js"; 6 | import VideoChunk from "./VideoChunk.js"; 7 | import Synthesizer from "./Synthesizer.js"; 8 | 9 | export { 10 | CaptureContext, 11 | ResourcePool, 12 | Browser, 13 | Page, 14 | ChunkSynthesizer, 15 | VideoChunk, 16 | Synthesizer 17 | }; -------------------------------------------------------------------------------- /docs/capture-ctx.md: -------------------------------------------------------------------------------- 1 | # CaptureContext 2 | 3 | 捕获上下文,可以从这里获得捕获相关的参数或改变一些东西。 4 | 5 | WVC会将此上下文实例暴露到 `window.captureCtx` 以便您的页面访问。 6 | 7 | ## CaptureContext.start() 8 | 9 | 开始捕获页面,视频实例的 `autostartRender` 选项为false时,必须调用此函数才能启动渲染。 10 | 11 | ## CaptureContext.addAudio(options: Object) 12 | 13 | 添加音频,也可以在页面中插入 `