IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    通过服务器在指定时间将网页录制成视频

    Ashes Born\'s Blog发表于 2021-07-16 05:51:36
    love 0

    通过服务器在指定时间将网页录制成视频#

    • 为什么有这样的需求?
    • 我的目标
    • 技术栈的选择
    • 具体的实现方式
      • 一、现行方案
        • 该方案主要规避解决的问题:
        • 核心流程
        • 关键点:
        • 方案性能(docker中)
      • 二、尝试过的方案
        • getDisplayMedia模式
          • 关键点
    • Q&A
    • 项目地址

    为什么有这样的需求?#

    笔者最近的工作在前端数据可视化领域,会出现一些对长时间运行的前端页面进行监控的需求。以往我的解决办法是通过一些现有的平台,在个人PC上通过浏览器进行录制,或者更早的方法是通过一些录屏工具进行录制。

    在这样的方式中,经常会遇到以下问题:

    • 分辨率不够还原
    • 录制的日志格式难以解析
    • 需要长期的打开个人电脑
    • 通过平台录制的,往往不是视频,而是一段DOM-Mirror的记录。这样的记录很难分享给其他人进行问题排查
    • DOM-Mirror记录进行回放时,对于后端返回的实时数据渲染,缺少价值(因为当时的时间点已经错过了,回放时无法回放后端当时的服务状态)
    • 并发录制个数受限于个人电脑的性能
    • 录制后的文件不好管理

    我的目标#

    So,基于上述的需求,我们需要达到以下的要求:

    • 能在网页要求的原始分辨率情况下进行录制
    • 能在服务端而不是个人电脑上进行录制
    • 能录制通用的视频和日志文件,可以方便的分享给他人
    • 能进行并发录制
    • 视频帧数要足够流畅(至少4K下)
    • 为录制的文件提供静态资源访问服务

    技术栈的选择#

    • 基础语言和框架——js&nodejs
    • 对于指定时间运行任务 —— cron job
    • 对于打开网页 —— puppeteer
    • 对于视频录制有以下备选方案
      • 使用浏览器api getDisplayMedia进行录制
      • 使用puppeteer按帧数截图,然后对图片用ffmpeg进行压制
      • 使用xvfb将虚拟桌面的视频流直接通过ffmpeg进行编码录制
    • 对于录制日志 —— puppeteer提供的devtools相关事件
    • 对于并发处理 —— 引入加权计算
    • 对于视频处理 —— ffmpeg

    具体的实现方式#

    一、现行方案#

    该方案主要规避解决的问题:#

    • 使用 getDisplayMedia时,受到浏览器的协议限制。这个api只在访问协议为https下可用,且音频的录制需要依赖其他的api。
    • getDisplayMedia的性能,在多网页并发录制时优化空间小,而且最致命的问题时,录制过程的性能开销,是由浏览器负担的。这意味着,如果页面本身对性能比较敏感,使用这个api基本无法录制出网页正常运行的情况。
    • puppeteer按帧数截图受到了chrome-devtools本身的限制,导致一秒只能截取出10+图。在数据可视化的场景中,大量的实时数据渲染,显然也是无法接受的。

    核心流程#

    关键点:#

    1. 使用node调用xvfb,创建虚拟桌面:开源库node-xvfb存在一些问题,创建的虚拟桌面,似乎共享了同一个流的缓冲区,在并发录制时,会出现抢占的情况,导致视频内容出现加速,所以需要封装一个新的node调用xvfb的功能
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    import * as process from 'child_process';
    class XvfbMap {
    private xvfb: {
    [key: string]: {
    process: process.ChildProcessWithoutNullStreams;
    display: number;
    execPath?: string;
    };
    } = {};

    setXvfb = (key: string, display: number, process: process.ChildProcessWithoutNullStreams, execPath?: string) => {
    this.xvfb[key] = {
    display,
    process,
    execPath,
    };
    };

    getSpecXvfb = (key: string) => {
    return this.xvfb[key];
    };

    getXvfb = () => this.xvfb;
    }

    const xvfbIns = new XvfbMap();

    /**
    * 检测虚拟桌面是否运行
    * @param num 虚拟桌面窗口编号
    * @param execPath 内存缓冲文件映射路径
    * @returns Promise<boolean>
    */
    const checkoutDisplay = (num: number, execPath?: string) => {
    const path = execPath || '/dev/null';
    return new Promise<boolean>((res, rej) => {
    const xdpyinfo = process.spawn('xdpyinfo', [
    '-display',
    `:${num}>${path}`,
    '2>&1',
    '&&',
    'echo',
    'inUse',
    '||',
    'echo',
    'free',
    ]);
    xdpyinfo.stdout.on('data', (data) => res(data.toString() === 'inUse'));
    xdpyinfo.stderr.on('data', (data) => rej(data.toString()));
    });
    };

    const getRunnableNumber = async (execPath?: string): Promise<number> => {
    const num = Math.floor(62396 * Math.random());
    const isValid = await checkoutDisplay(num, execPath);
    if (isValid) {
    return num;
    } else {
    return getRunnableNumber(execPath);
    }
    };

    export const xvfbStart = async (
    key: string,
    option: { width: number; height: number; depth: 15 | 16 | 24 },
    execPath?: string
    ) => {
    const randomNum = Math.floor(62396 * Math.random());
    const { width, height, depth } = option;
    try {
    const xvfb = process.spawn('Xvfb', [
    `:${randomNum}`,
    '-screen',
    '0',
    `${width}x${height}x${depth}`,
    '-ac',
    '-noreset',
    ]);

    xvfbIns.setXvfb(key, randomNum, xvfb, execPath);
    return randomNum;
    } catch (error) {
    console.log(error);
    return 99;
    }
    };

    export const xvfbStop = (key: string) => {
    const xvfb = xvfbIns.getSpecXvfb(key);
    return xvfb.process.kill();
    };

    export default xvfbIns;

    1. 服务器并发录制时进行负载均衡。这个功能是为解决并发录制视频编码时,服务器CPU的负载过高问题。所以为了尽可能的提高并发录制数量,我记录了每个服务器正在和将要执行的任务数量,将这个数量标记为服务的权重,当创建一个新的录制任务时,先检测当前服务器的权重,然后在权重最低的服务器上创建录制任务,并在录制完成和手动终止任务时,降低权值。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    import { CronJob } from 'cron';

    interface CacheType {
    [key: string]: CronJob;
    }

    class CronCache {
    private cache: CacheType = {};
    private cacheCount = 0;
    setCache = (key: string, value: CronJob) => {
    this.cache[key] = value;
    this.cacheCount++;
    return;
    };

    getCache = (key: string) => {
    return this.cache[key];
    };

    deleteCache = (key: string) => {
    if (this.cache[key]) {
    delete this.cache[key];
    }

    this.cacheCount = this.cacheCount > 0 ? this.cacheCount - 1 : 0;
    };

    getCacheCount = () => this.cacheCount;
    getCacheMap = () => this.cache;
    }

    export default new CronCache();

    1. 启动puppeteer时,需要提供一系列参数

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      const browser = await puppeteer.launch({
      headless: false,
      executablePath: '/usr/bin/google-chrome',
      defaultViewport: null,
      args: [
      '--enable-usermedia-screen-capturing',
      '--allow-http-screen-capture',
      '--ignore-certificate-errors',
      '--enable-experimental-web-platform-features',
      '--allow-http-screen-capture',
      '--disable-infobars',
      '--no-sandbox',
      '--disable-setuid-sandbox',//关闭沙箱
      '--start-fullscreen',
      '--display=:' + display,
      '-–disable-dev-shm-usage',
      '-–no-first-run', //没有设置首页。
      '–-single-process', //单进程运行
      '--disable-gpu', //GPU硬件加速
      `--window-size=${width},${height}`,//窗口尺寸
      ],
      });

    方案性能(docker中)#

    • 标准1k分辨率下:双核CPU 2.3Ghz; 4G ram下,并发数10个
    • 标准2k分辨率下:双核CPU 2.3Ghz; 4G ram下,并发数4个

    二、尝试过的方案#

    getDisplayMedia模式#

    关键点#
    1. 该api的调用,会导致chrome弹出选择具体录制哪个网页的交互窗口。关闭这个窗口需要在启动puppeteer时启用以下参数

      1
      2
      3
      4
      5
      6
      7
      8
      9
      '--enable-usermedia-screen-capturing',
      `--auto-select-desktop-capture-source=recorder-page`,
      '--allow-http-screen-capture',
      '--ignore-certificate-errors',
      '--enable-experimental-web-platform-features',
      '--allow-http-screen-capture',
      '--disable-infobars',
      '--no-sandbox',
      '--disable-setuid-sandbox',
    2. 执行录制时,需要通过puppeteer page.exposeFunction注入函数进行执行。

    Q&A#

    Q:为什么要引入xvfb?

    A:在尝试的方案中,getDisplayMedia需要运行环境提供一个桌面环境。在现行方案中,则是需要把xvfb的视频流直接推入到ffmpeg中

    Q:为什么对内存有一定要求?

    A:提供chrome的最小运行内存

    项目地址#

    https://github.com/sadofriod/time-recorder



沪ICP备19023445号-2号
友情链接