web-video-creator
Version:
A framework for creating videos based on Node.js + Puppeteer + FFmpeg.
1,009 lines (953 loc) • 36.5 kB
JavaScript
import assert from "assert";
import path from "path";
import AsyncLock from "async-lock";
import EventEmitter from "eventemitter3";
import { Page as _Page, CDPSession, HTTPRequest, HTTPResponse } from "puppeteer-core";
import fs from "fs-extra";
import _ from "lodash";
import Browser from "./Browser.js";
import CaptureContext from "./CaptureContext.js";
import SvgAnimation from "../media/SvgAnimation.js";
import VideoCanvas from "../media/VideoCanvas.js";
import DynamicImage from "../media/DynamicImage.js";
import LottieCanvas from "../media/LottieCanvas.js";
import MP4Demuxer from "../media/MP4Demuxer.js";
import VideoConfig from "../preprocessor/video/VideoConfig.js";
import Audio from "../entity/Audio.js";
import Font from "../entity/Font.js";
import globalConfig from "../lib/global-config.js";
import logger from "../lib/logger.js";
import innerUtil from "../lib/inner-util.js";
import util from "../lib/util.js";
/**
* @typedef {import('puppeteer-core').Viewport} Viewport
* @typedef {import('puppeteer-core').WaitForOptions} WaitForOptions
*/
// 默认用户UA
const DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0";
// 公共样式内容
const COMMON_STYLE_CONTENT = fs.readFileSync(util.rootPathJoin("lib/common.css"), "utf-8");
// MP4Box库脚本内容
const MP4BOX_LIBRARY_SCRIPT_CONTENT = fs.readFileSync(util.rootPathJoin("lib/mp4box.js"), "utf-8");
// Webfont库脚本内容
const FONTFACE_OBSERVER_SCRIPT_CONTENT = fs.readFileSync(util.rootPathJoin("lib/fontfaceobserver.js"), "utf-8");
// Lottie动画库脚本内容
const LOTTIE_LIBRARY_SCRIPT_CONTENT = fs.readFileSync(util.rootPathJoin("lib/lottie.js"), "utf-8");
// 页面计数
let pageIndex = 1;
/**
* 页面
*/
export default class Page extends EventEmitter {
/** 页面状态枚举 */
static STATE = {
/** 未初始化 */
UNINITIALIZED: Symbol("UNINITIALIZED"),
/** 已就绪 */
READY: Symbol("READY"),
/** 录制中 */
CAPTURING: Symbol("CAPTURING"),
/** 已暂停 */
PAUSED: Symbol("PAUSED"),
/** 已停止 */
STOPPED: Symbol("STOPPED"),
/** 不可用 */
UNAVAILABLED: Symbol("UNAVAILABLED"),
/** 已关闭 */
CLOSED: Symbol("CLOSED")
};
id = `Page@${pageIndex++}`;
/** @type {Page.STATE} */
state = Page.STATE.UNINITIALIZED;
/** @type {Browser} */
parent;
/** @type {_Page} */
target;
/** @type {number} - 页面视窗宽度 */
width;
/** @type {number} - 页面视窗高度 */
height;
/** @type {string} - 用户UA */
userAgent;
/** @type {number} - BeginFrame超时时间(毫秒) */
beginFrameTimeout;
/** @type {string} - 帧图格式(jpeg/png) */
frameFormat;
/** @type {number} - 帧图质量(0-100) */
frameQuality;
/** @type {number} - 背景不透明度(0-1) */
backgroundOpacity = 1;
/** @type {Font[]} - 已注册字体集 */
fonts = [];
/** @type {Object[]} - 已接受资源列表 */
acceptResources = [];
/** @type {Object[]} - 已拒绝资源列表 */
rejectResources = [];
/** @type {Object[]} - CSS动画列表 */
cssAnimations = [];
/** @type {{[key: number]: Function}} - CSS动画列表 */
timeActions = {}
/** @type {Set} - 资源排重Set */
#resourceSet = new Set();
/** @type {CDPSession} - CDP会话 */
#cdpSession = null;
/** @type {boolean} - 是否初始页面 */
#firstPage = false;
/** @type {AsyncLock} - */
#asyncLock = new AsyncLock();
/**
* 构造函数
*
* @param {Object} options - 页面选项
* @property {number} [options.width] - 页面视窗宽度
* @property {number} [options.height] - 页面视窗高度
* @property {string} [options.userAgent] - 用户UA
* @property {number} [options.beginFrameTimeout=5000] - BeginFrame超时时间(毫秒)
* @property {string} [options.frameFormat="jpeg"] - 帧图格式(jpeg/png)
* @property {number} [options.frameQuality=80] - 帧图质量(0-100)
*/
constructor(parent, options) {
super();
assert(parent instanceof Browser, "Page parent must be Browser");
this.parent = parent;
assert(_.isObject(options), "Page options must provided");
const { width, height, userAgent, beginFrameTimeout,
frameFormat, frameQuality, _firstPage = false } = options;
assert(_.isUndefined(width) || _.isFinite(width), "Page width must be number");
assert(_.isUndefined(height) || _.isFinite(height), "Page height must be number");
assert(_.isUndefined(userAgent) || _.isString(userAgent), "Page userAgent must be string");
assert(_.isUndefined(beginFrameTimeout) || _.isFinite(beginFrameTimeout), "Page beginFrameTimeout must be number");
assert(_.isUndefined(frameQuality) || _.isFinite(frameQuality), "Page frameQuality must be number");
assert(_.isUndefined(frameFormat) || _.isString(frameFormat), "Page frameFormat must be string");
assert(_.isBoolean(_firstPage), "Page _firstPage must be boolean");
this.width = width;
this.height = height;
this.userAgent = _.defaultTo(userAgent, _.defaultTo(globalConfig.userAgent, DEFAULT_USER_AGENT));
this.beginFrameTimeout = _.defaultTo(beginFrameTimeout, _.defaultTo(globalConfig.beginFrameTimeout, 5000));
this.frameFormat = _.defaultTo(frameFormat, _.defaultTo(globalConfig.frameFormat, "jpeg"));
this.frameQuality = _.defaultTo(frameQuality, _.defaultTo(globalConfig.frameQuality, 80));
this.#firstPage = _firstPage;
}
/**
* 初始化页面
*/
async init() {
await this.#asyncLock.acquire("init", async () => {
// 如果是浏览器首个页面将复用已开启的第一个页面
if (this.#firstPage)
this.target = (await this.parent.target.pages())[0];
else
this.target = await this.parent.target.newPage();
// 初始化渲染环境
await this.#envInit();
// 设置页面已就绪
this.#setState(Page.STATE.READY);
});
}
/**
* 设置视窗
*
* @param {Viewport} options - 视窗选项
*/
async setViewport(options = {}) {
const { width, height } = options;
assert(_.isFinite(width), "Page viewport width must be number");
assert(_.isFinite(height), "Page viewport height must be number");
this.width = width;
this.height = height;
// 设置页面视窗
await this.target.setViewport({
...options,
width: Math.floor(width),
height: Math.floor(height)
});
}
/**
* 导航URL
*
* @param {string} url - 导航目标URL
* @param {WaitForOptions} [waitForOptions] - 等待选项
*/
async goto(url, waitForOptions) {
assert(this.isReady(), "Page state must be ready");
assert(util.isURL(url), "goto url is invalid");
// 清除资源
this.#resetStates();
// 检查URL
!globalConfig.allowUnsafeContext && this.#checkURL(url);
// 开始CDP会话
await this.#startCDPSession();
// 监听CSS动画
await this.#listenCSSAnimations();
// 页面导航到URL
await this.target.goto(url, waitForOptions);
await Promise.all([
// 注入公共样式
this.#injectStyle(COMMON_STYLE_CONTENT),
// 注入MP4Box库
this.#injectLibrary(MP4BOX_LIBRARY_SCRIPT_CONTENT + ";window.____MP4Box = window.MP4Box;window.MP4Box = undefined"),
// 注入Lottie动画库
this.#injectLibrary(LOTTIE_LIBRARY_SCRIPT_CONTENT + ";window.____lottie = window.lottie;window.lottie = undefined")
]);
// 初始化捕获上下文
await this.target.evaluate(() => captureCtx.init());
}
/**
* 设置页面内容
*
* @param {string} content 页面内容
* @param {WaitForOptions} [waitForOptions] - 等待选项
*/
async setContent(content, waitForOptions) {
assert(this.isReady(), "Page state must be ready");
assert(_.isString(content), "page content must be string");
await this.target.goto("about:blank");
// 清除资源
this.#resetStates();
// 开始CDP会话
await this.#startCDPSession();
// 监听CSS动画
await this.#listenCSSAnimations();
await this.target.setContent(content, waitForOptions);
await Promise.all([
// 注入公共样式
this.#injectStyle(COMMON_STYLE_CONTENT),
// 注入MP4Box库
this.#injectLibrary(MP4BOX_LIBRARY_SCRIPT_CONTENT + ";window.____MP4Box = window.MP4Box;window.MP4Box = undefined"),
// 注入Lottie动画库
this.#injectLibrary(LOTTIE_LIBRARY_SCRIPT_CONTENT + ";window.____lottie = window.lottie;window.lottie = undefined")
]);
// 初始化捕获上下文
await this.target.evaluate(() => captureCtx.init());
}
/**
* 设置背景不透明度(0-1)
*
* @param {number} [opacity=1] - 背景不透明度
*/
setBackgroundOpacity(opacity = 1) {
assert(this.isReady(), "Page state must be ready");
assert(_.isFinite(opacity), "opacity must be number");
this.backgroundOpacity = opacity;
}
/**
* 注册字体
*
* @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));
}
/**
* 注册动作序列
* @param {Object} timeActions
*/
registerTimeActions(timeActions) {
this.timeActions = { ...timeActions };
}
/**
* 等待字体加载完成
*
* @param {number} [timeout=30000] - 等待超时时间(毫秒)
*/
async waitForFontsLoaded(timeout = 30000) {
// 注入Webfont库
await this.#injectLibrary(FONTFACE_OBSERVER_SCRIPT_CONTENT + ";window.____FontFaceObserver = FontFaceObserver");
// 等待字体加载完成
await Promise.all(this.fonts.map(font => font.load()));
// 将所有字体声明拼接为样式
const styles = this.fonts.reduce((style, font) => style + font.toFontFace(), "");
// 添加样式标签到页面
styles && await this.#injectStyle(styles);
await this.target.evaluate(async _timeout => {
const fonts = [...document.fonts];
// 无字体则跳过加载
if (fonts.length == 0)
return;
// 等待字体加载完成
let timer;
await Promise.race([
Promise.all(fonts.map(font => new ____FontFaceObserver(font.family).load())),
new Promise((_, reject) => timer = (window.____setTimeout || window.setTimeout)(reject, _timeout))
]);
(window.____clearTimeout || window.clearTimeout)(timer);
}, timeout);
}
/**
* 注入样式
*
* @param {string} content - 样式内容
*/
async #injectStyle(content) {
assert(_.isString(content), "inject style content must be string");
await this.target.addStyleTag({
content
});
}
/**
* 注入脚本库
*
* @param {string} content - 脚本内容
*/
async #injectLibrary(content) {
assert(_.isString(content), "inject script content must be string");
await this.target.addScriptTag({
content
});
}
/**
* 开始录制
*
* @param {Object} [options] - 录制选项
* @param {number} [options.fps] - 渲染帧率
* @param {number} [options.startTime=0] - 渲染开始事件点(毫秒)
* @param {number} [options.duration] - 渲染时长(毫秒)
* @param {number} [options.frameCount] - 渲染总帧数
* @param {boolean} [options.autostart=true] - 是否自动启动渲染
*/
async startScreencast(options = {}) {
await this.#asyncLock.acquire("startScreencast", async () => {
let { fps, startTime = 0, duration, frameCount, autostart = true } = options;
assert(this.isReady(), "Page state must be ready");
assert(_.isUndefined(fps) || _.isFinite(fps), "fps must be number");
assert(_.isFinite(startTime), "startTime must be number");
assert(_.isUndefined(duration) || _.isFinite(duration), "duration must be number");
assert(_.isUndefined(frameCount) || _.isFinite(frameCount), "frameCount must be number");
// 指定时长时将计算总帧数
if (_.isFinite(duration))
frameCount = util.durationToFrameCount(duration, fps);
else if (_.isFinite(frameCount))
duration = util.frameCountToDuration(frameCount, fps);
// 页面进入捕获中状态
this.#setState(Page.STATE.CAPTURING);
// 当当前视图与设定不一致时进行调整
const { width, height, ..._options } = this.target.viewport() || {};
if (width != this.width || height != this.height)
await this.setViewport({ width, height, ..._options });
// 应用背景不透明度
await this.#applyBackgroundOpacity();
// 将鼠标移动到屏幕中央
await this.target.mouse.move(width / 2, height / 2);
// 如果设置帧率或者总帧数将覆盖页面中设置的帧率和总帧数
await this.target.evaluate(async config => {
// 注入配置选项
Object.assign(captureCtx.config, config);
// 如果准备后还未启动且自动启动选项开启时渲染则开始
!captureCtx.ready() && captureCtx.config.autostart && captureCtx.start();
}, _.pickBy({ fps, startTime, duration, frameCount, autostart }, v => !_.isUndefined(v)));
});
}
/**
* 暂停录制
*/
async pauseScreencast() {
assert(this.isCapturing(), "Page state is not capturing, unable to pause");
await this.target.evaluate(async () => captureCtx.pauseFlag = true);
this.#setState(Page.STATE.PAUSED);
}
/**
* 恢复录制
*/
async resumeScreencast() {
assert(this.isPaused(), "Page state is not paused, unable to resume");
await this.target.evaluate(async () => {
if (captureCtx.resumeCallback) {
captureCtx.resumeCallback();
captureCtx.resumeCallback = null;
}
captureCtx.pauseFlag = false;
});
this.#setState(Page.STATE.CAPTURING);
}
/**
* 停止录制
*/
async stopScreencast() {
await this.#asyncLock.acquire("stopScreencast", async () => {
await this.target.evaluate(async () => captureCtx.stopFlag = true);
await this.#endCDPSession();
this.#setState(Page.STATE.STOPPED);
});
}
/**
* @typedef {Object} CaptureContextConfig
* @property {number} fps - 捕获帧率
* @property {number} frameCount - 捕获总帧数
*/
/**
* 获取捕获上下文配置
*
* @returns {CaptureContextConfig} - 配置对象
*/
async getCaptureContextConfig() {
return await this.target.evaluate(() => captureCtx.config);
}
/**
* 发送错误事件
*
* @param {Error} err - 错误对象
*/
#emitError(err) {
if (err.message.indexOf("Another frame is pending") != -1)
err = new Error("Page rendering has been interrupted");
if (this.eventNames().indexOf("error") != -1)
this.emit("error", err);
else
logger.error("Page error:", err);
}
/**
* 发送崩溃事件
*
* @param {Error} err - 错误对象
*/
#emitCrashed(err) {
// 设置页面为不可用
this.#setState(Page.STATE.UNAVAILABLED);
if (this.eventNames().indexOf("crashed") != -1)
this.emit("crashed", err);
else
logger.error("Page crashed:", err);
}
/**
* 发送录制完成事件
*/
#emitScreencastCompleted() {
this.emit("screencastCompleted");
}
/**
* 环境初始化
*/
async #envInit() {
// 设置UserAgent防止页面识别HeadlessChrome
await this.target.setUserAgent(this.userAgent);
// 禁用CSP策略
await this.target.setBypassCSP(true);
// 拦截请求
await this.target.setRequestInterception(true);
// 页面控制台输出
this.target.on("console", message => {
const type = message.type();
const text = message.text();
// 错误消息处理
if (type === "error") {
if (text.indexOf("Failed to load resource: the server responded with a status of ") != -1)
return;
this.emit("consoleError", new PageError(text));
}
// 其它消息处理
else
this.emit("consoleLog", text);
});
// 页面加载完成事件
this.target.on("domcontentloaded", async () => {
// 如果处于录制状态作为被动刷新处理
if (this.isCapturing())
this.#emitError(new Error("Page context is missing, possibly due to the page being refreshed"))
});
// 页面请求处理
this.target.on("request", this.#requestHandle.bind(this));
// 页面响应处理
this.target.on("response", this.#responseHandle.bind(this));
// 页面错误回调
this.target.on("pageerror", err => this.emit("consoleError", new PageError(err)));
// 页面崩溃回调
this.target.once("error", this.#emitCrashed.bind(this));
// 页面关闭回调
this.target.once("close", this.close.bind(this));
// 暴露录制完成函数
await this.target.exposeFunction("____screencastCompleted", this.#emitScreencastCompleted.bind(this));
// 暴露CSS动画控制函数
await this.target.exposeFunction("____seekCSSAnimations", this.#seekCSSAnimations.bind(this));
// 暴露动作序列
await this.target.exposeFunction("____seekTimeActions", this.#seekTimeActions.bind(this));
// 暴露跳帧函数
await this.target.exposeFunction("____skipFrame", this.#skipFrame.bind(this));
// 暴露下一帧函数
await this.target.exposeFunction("____captureFrame", this.#captureFrame.bind(this));
// 暴露添加音频函数
await this.target.exposeFunction("____addAudio", this.#addAudio.bind(this));
await this.target.exposeFunction("____updateAudioEndTime", this.#updateAudioEndTime.bind(this));
// 暴露抛出错误函数
await this.target.exposeFunction("____throwError", (code = -1, message = "") => this.#emitError(new Error(`throw error: [${code}] ${message}`)));
// 页面加载前进行上下文初始化
await this.target.evaluateOnNewDocument(`
window.____util=(${innerUtil})();
window.____MP4Demuxer=${MP4Demuxer};
window.____SvgAnimation=${SvgAnimation};
window.____VideoCanvas=${VideoCanvas};
window.____DynamicImage=${DynamicImage};
window.____LottieCanvas=${LottieCanvas};
window.____CaptureContext=${CaptureContext};
window.captureCtx=new ____CaptureContext();
`);
}
/**
* seek所有CSS动画
*/
async #seekCSSAnimations(currentTime) {
if (this.cssAnimations.length === 0)
return;
const pauseAnimationIds = [];
const seekPromises = [];
this.cssAnimations = this.cssAnimations.filter(animation => {
if (animation.startTime == null)
pauseAnimationIds.push(animation.id);
animation.startTime = _.defaultTo(animation.startTime, currentTime);
const animationCurrentTime = Math.floor(currentTime - animation.startTime);
if (animationCurrentTime < 0)
return true;
seekPromises.push(this.#cdpSession.send("Animation.seekAnimations", {
animations: [animation.id],
currentTime: animationCurrentTime
}));
if (animationCurrentTime >= (animation.duration * (animation.iterations || Infinity)) + animation.delay)
return false;
return true;
});
// 暂停动画
if (pauseAnimationIds.length > 0) {
await this.#cdpSession.send("Animation.setPaused", {
animations: pauseAnimationIds,
paused: true
});
}
// 调度动画
await Promise.all(seekPromises);
}
/**
* seek所有时间轴动作
*/
async #seekTimeActions(currentTime) {
currentTime = parseInt(currentTime)
const matchTimeNodes = Object.keys(this.timeActions)
.map(Number)
.sort()
.find(time => currentTime >= time);
if(!matchTimeNodes)
return;
const timeAction = this.timeActions[matchTimeNodes];
delete this.timeActions[matchTimeNodes]
try {
const result = timeAction(this);
if(result instanceof Promise)
await result.catch(err => this.#emitError(err))
}
catch(err) {
this.#emitError(err)
}
}
/**
* 跳过帧
*/
async #skipFrame() {
if (globalConfig.compatibleRenderingMode)
return;
let timer;
// 帧数据捕获
const frameData = await Promise.race([
this.#cdpSession.send("HeadlessExperimental.beginFrame"),
// 帧渲染超时处理
new Promise(resolve => timer = setTimeout(() => resolve(false), this.beginFrameTimeout))
]);
clearTimeout(timer);
// 帧渲染超时处理
if (frameData === false) {
this.#setState(Page.STATE.UNAVAILABLED);
throw new Error("beginFrame wait timeout");
}
}
/**
* 捕获帧
*/
async #captureFrame() {
try {
// 非兼容渲染模式使用BeginFrame API进行捕获否则使用截图API
const frameFormat = this.backgroundOpacity < 1 ? "png" : this.frameFormat;
if (!globalConfig.compatibleRenderingMode) {
let timer;
// 帧数据捕获
const frameData = await Promise.race([
this.#cdpSession.send("HeadlessExperimental.beginFrame", {
screenshot: {
// 帧图格式(jpeg, png)
format: frameFormat,
// 帧图质量(0-100)
quality: frameFormat == "jpeg" ? this.frameQuality : undefined
}
}),
// 帧渲染超时处理
new Promise(resolve => timer = setTimeout(() => resolve(false), this.beginFrameTimeout))
]);
clearTimeout(timer);
// 帧渲染超时处理
if (frameData === false) {
this.#setState(Page.STATE.UNAVAILABLED);
throw new Error("beginFrame wait timeout");
}
if (!frameData || !frameData.screenshotData) return true;
this.emit("frame", Buffer.from(frameData.screenshotData, "base64"));
}
else {
const screenshotData = await this.target.screenshot({
type: frameFormat,
quality: frameFormat == "jpeg" ? this.frameQuality : undefined,
optimizeForSpeed: true
});
// 帧数据回调
this.emit("frame", screenshotData);
}
return true;
}
catch (err) {
this.#emitError(err);
return false;
}
}
/**
* 添加音频
*
* @param {Audio} options
*/
#addAudio(options) {
this.emit("audioAdd", new Audio(options));
}
/**
* 更新音频结束时间点
*
* @param {number} audioId - 内部音频ID
* @param {number} endTime - 音频结束时间点
*/
#updateAudioEndTime(audioId, endTime) {
this.emit("audioUpdate", audioId, { endTime });
}
/**
* 预处理视频
*
* @param {VideoConfig} config - 视频配置
*/
async #preprocessVideo(config) {
const videoPreprocessor = this.videoPreprocessor;
this.emit("videoPreprocess", config);
const { audio, buffer } = await videoPreprocessor.process(config);
audio && this.emit("audioAdd", audio);
return buffer;
}
/**
* 开始CDP会话
*/
async #startCDPSession() {
this.#cdpSession && await this.#endCDPSession();
this.#cdpSession = await this.target.createCDPSession(); //创建会话
}
/**
* 应用背景透明度
*/
async #applyBackgroundOpacity() {
await this.#cdpSession.send("Emulation.setDefaultBackgroundColorOverride", {
color: { r: 0, g: 0, b: 0, a: this.backgroundOpacity }
});
}
/**
* 监听CSS动画
*/
async #listenCSSAnimations() {
// 启用动画通知域
await this.#cdpSession.send("Animation.enable");
// 监听动画开始事件将动画属性添加到调度列表
this.#cdpSession.on("Animation.animationStarted", animation => {
this.cssAnimations.push({
id: animation.animation.id,
startTime: null,
paused: false,
backendNodeId: animation.animation.source.backendNodeId,
delay: animation.animation.source.delay,
duration: animation.animation.source.duration,
iterations: animation.animation.source.iterations
});
});
}
/**
* 结束CDP会话
*/
async #endCDPSession() {
if (!this.#cdpSession) return;
await new Promise(resolve => {
// 移除所有事件监听器
this.#cdpSession.removeAllListeners();
// 从页面卸载CDP会话
this.#cdpSession.detach()
.catch(err => this.emit("consoleError", err))
.finally(() => {
this.#cdpSession = null;
resolve();
});
});
}
/**
* 页面请求处理
*
* @param {HTTPRequest} request - 页面请求
*/
#requestHandle(request) {
(async () => {
// 如果是捕获中产生的跳转请求则终止
if (this.isCapturing() && request.isNavigationRequest() && request.frame() === this.target.mainFrame()) {
request.abort("aborted");
return;
}
const method = request.method();
const url = request.url();
const { pathname } = new URL(url);
// console.log(pathname);
// 视频预处理API
if (method == "POST" && pathname == "/api/video_preprocess") {
const data = _.attempt(() => JSON.parse(request.postData()));
if (_.isError(data))
throw new Error("api /api/video_preprocess only accept JSON data");
const buffer = await this.#preprocessVideo(new VideoConfig(data));
await request.respond({
status: 200,
body: buffer
});
}
// 从本地拉取字体
else if (method == "GET" && /^\/local_font\//.test(pathname)) {
const filePath = path.join("tmp/local_font/", pathname.substring(12));
if (!await fs.pathExists(filePath)) {
return await request.respond({
status: 404,
body: "File not exists"
});
}
else {
await request.respond({
status: 200,
body: await fs.readFile(filePath),
headers: {
// 要求浏览器缓存字体
"Cache-Control": "max-age=31536000"
}
});
}
}
// 其它请求透传
else
await request.continue();
})()
.catch(err => {
logger.error(err);
// 发生错误响应500
request.respond({
status: 500,
body: err.stack
})
.catch(err => logger.error(err));
})
}
/**
* 页面响应处理
*
* @param {HTTPResponse} response - HTTP响应
*/
#responseHandle(response) {
const status = response.status();
const statusText = response.statusText();
const method = response.request().method();
const url = response.url();
const id = `${method}:${url}`;
if (this.#resourceSet.has(id))
return;
this.#resourceSet.add(id);
const info = {
status,
statusText,
method,
url
};
if (status < 400) {
this.acceptResources.push(info);
this.emit("resourceAccepted", info);
}
else {
this.rejectResources.push(info);
const message = `Fetch response failed: [${method}] ${url} - [${status}] ${statusText}`;
if (this.eventNames().indexOf("resourceError") != -1)
this.emit("resourceRejected", new Error(message));
else
logger.error(message);
}
}
/**
* 重置页面
*/
async reset() {
await this.#asyncLock.acquire("reset", async () => {
// 如果处于捕获状态则停止录制
this.isCapturing() && await this.stopScreencast();
// 如果CDP会话存在则结束会话
this.#cdpSession && await this.#endCDPSession();
// 移除监听器
this.#removeListeners();
// 清除资源
this.#resetStates();
this.#resourceSet = new Set();
// 跳转空白页释放页面内存
await this.target.goto("about:blank");
// 设置页面状态为ready
this.#setState(Page.STATE.READY);
});
}
/**
* 释放页面资源
*/
async release() {
await this.#asyncLock.acquire("release", async () => {
// 重置页面
await this.reset();
// 通知浏览器释放页面
await this.parent.releasePage(this);
// 设置页面状态为ready
this.#setState(Page.STATE.READY);
});
}
/**
* 关闭页面
*/
async close() {
await this.#asyncLock.acquire("close", async () => {
if (this.isClosed())
return;
// 设置页面状态为closed
this.#setState(Page.STATE.CLOSED);
// 通知浏览器页面池销毁页面资源
await this.parent.destoryPage(this);
// 如果页面已关闭则跳过
if (!this.target || this.target.isClosed())
return;
this.target.close();
this.target = null;
});
}
/**
* 检查URL
*
* @param {string} url - URL
*/
#checkURL(url) {
const { protocol, hostname, host } = new URL(url);
if (protocol != "https:" && hostname != "127.0.0.1" && hostname != "localhost")
throw new Error(`The URL ${protocol}//${host} is not a secure domain, which may cause security policies to disable some core features. Please use HTTPS protocol or http://localhost / http://127.0.0.1`);
}
/**
* 重置状态
*/
#resetStates() {
this.backgroundOpacity = 1;
this.fonts = [];
this.acceptResources = [];
this.rejectResources = [];
this.cssAnimations = [];
this.timeActions = {};
}
/**
* 移除所有监听器
*/
#removeListeners() {
this.removeAllListeners("frame");
this.removeAllListeners("screencastCompleted");
this.removeAllListeners("consoleLog");
this.removeAllListeners("consoleError");
this.removeAllListeners("resourceAccepted");
this.removeAllListeners("resourceRejected");
this.removeAllListeners("videoPreprocess");
this.removeAllListeners("audioAdd");
this.removeAllListeners("audioUpdate");
this.removeAllListeners("error");
this.removeAllListeners("crashed");
}
/**
* 设置页面资源状态
*
* @param {Page.STATE} state
*/
#setState(state) {
assert(_.isSymbol(state), "state must be Symbol");
this.state = state;
}
/**
* 是否未初始化
*
* @returns {boolean} - 是否未初始化
*/
isUninitialized() {
return this.state == Page.STATE.UNINITIALIZED;
}
/**
* 是否已就绪
*
* @returns {boolean} - 是否已就绪
*/
isReady() {
return this.state == Page.STATE.READY;
}
/**
* 是否正在捕获
*
* @returns {boolean} - 是否正在捕获
*/
isCapturing() {
return this.state == Page.STATE.CAPTURING;
}
/**
* 是否已暂停
*
* @returns {boolean} - 是否已暂停
*/
isPaused() {
return this.state == Page.STATE.PAUSED;
}
/**
* 是否不可用
*
* @returns {boolean} - 是否不可用
*/
isUnavailabled() {
return this.state == Page.STATE.UNAVAILABLED;
}
/**
* 是否已关闭
*
* @returns {boolean} - 是否已关闭
*/
isClosed() {
return this.state == Page.STATE.CLOSED;
}
/**
* 获取视频预处理器
*/
get videoPreprocessor() {
return this.parent.videoPreprocessor;
}
}
class PageError extends Error {
name = "PageError";
constructor(message) {
let stack;
if (message instanceof Error)
message = message.stack;
super(message);
stack && (this.stack = stack);
}
};