记一次PC页面录屏+转码+导出MP4

6,805 阅读6分钟

yf4gacmb859wmb8ido94.jpg

1. 产品需求

要求录制页面指定区域(canvas webgl),结束录制后,导出MP4,windows自带的播放器可播放。

2. 录制

2.1 rrweb

rrweb 是 'record and replay the web' 的简写,旨在利用现代浏览器所提供的强大 API 录制并回放任意 web 界面中的用户操作

  • rrweb对canvas的处理,默认情况下其内容不会被 rrweb 观测到,我们可以通过特定的配置让 rrweb 能够录制并回放 Canvas。

  • 录制时包含 Canvas 内的内容:

rrweb.record({
  emit(event) {},
  // 对 canvas 进行录制
  recordCanvas: true,
});
  • 回放时对 Canvas 进行回放,回放 Canvas 将会关闭沙盒策略,导致一定风险
const replayer = new rrweb.Replayer(events, {
  UNSAFE_replayCanvas: true,
});
replayer.play();
  • 兼容性

由于使用 MutationObserver API,rrweb 不支持 IE11 以下的浏览器

2.2 recordrtc

WebRTC JavaScript Library for Audio+Video+Screen+Canvas (2D+3D animation) Recording。WebRTC库可对音频、视频、屏幕和canvas(2D+3D)进行录制

  • 语法
let recorder = RecordRTC(MediaStream || HTMLCanvasElement || HTMLVideoElement || HTMLElement, {});
  • 注意:recordrtc依赖html2canvas库,否则报错。解决方法就是引入后,挂载到window对象上即可

  • console报错信息

20210802173620.png

  • RecordRTC.js报错位置

20210802173907.png

  • recordrtc的使用
import html2canvas from 'html2canvas';
import RecordRTC from 'recordrtc';

window.html2canvas = html2canvas;

// 初始化开始录制
const recorder = new RecordRTC(elementToRecordDOM, {
  type: 'canvas',
  mimeType: 'video/webm;codecs=h264',
  recorderType: RecordRTC.CanvasRecorder,
  disableLogs: true,
});

// 结束录制
recorder.stopRecording(() => {
  const blob = recorder.getBlob();
  // 导出视频,遗憾的是这里windows自带播放器无法播放,chrome浏览器和手机上都可以播放,后面会讲到如何转码
  RecordRTC.invokeSaveAsDialog(blob, 'test.mp4');
});
  • 兼容性

详见官网

2.3 navigator.mediaDevices.getDisplayMedia api

也可直接使用OpRec这个库

  • 语法
var promise = navigator.mediaDevices.getDisplayMedia(constraints);

  • 优点:录制并回放用户任意界面(不限于浏览器中)
  • 缺点:会弹出这个确认框,需要用户点击分享,结束后需要点击结束共享
  • 需要注意的是: 部署在线上http,浏览器出于安全考虑,navigator.mediaDevices获取可能会undefined,而本地开发localhost://file://协议访问及线上https://访问,是没问题的
  • html
<video id="video" autoplay muted style="width: 500px; height: 300px; border: 1px solid #ccc;"></video>
  • js
async function startCapture(displayMediaOptions) {
  try {
    captureStream = await navigator.mediaDevices.getDisplayMedia(displayMediaOptions);
    document.getElementById("video").srcObject = captureStream;
  } catch (err) {
    console.error("Error: " + err);
  }
}

startCapture({
  video: true,
  audio: false
});

Dingtalk_20210803095153.jpg

2.4 选择

这里选择使用 recordrtc进行录制导出

3. WebAssembly将webm格式转码为通用mp4

WebAssembly/wasm WebAssembly 或者 wasm 是一个可移植、体积小、加载快并且兼容 Web 的全新格式,实际上 wasm 是体积小且加载快的二进制格式,其目标就是充分发挥硬件能力以达到原生执行效率

3.1 ffmpeg.wasm

ffmpeg.wasm is a pure Webassembly / Javascript port of FFmpeg. It enables video & audio record, convert and stream right inside browsers. 可以在浏览器里进行视频和音频的录制和转码

3.1.1 安装
npm install @ffmpeg/ffmpeg @ffmpeg/core
3.1.2 使用
import FFmpeg from '@ffmpeg/ffmpeg';

async transAndDownload(blob) {
  // https://github.com/muaz-khan/RecordRTC/issues/464
  // ffmpeg fails to convert webm to mp4
  const { createFFmpeg, fetchFile } = FFmpeg;
  const ffmpeg = createFFmpeg({
    // https://github.com/ffmpegwasm/ffmpeg.wasm#why-it-doesnt-work-in-my-local-environment
  	// corePath: 'https://unpkg.com/@ffmpeg/core/dist/ffmpeg-core.js',
  	log: true,
  });

  await ffmpeg.load();
  ffmpeg.FS('writeFile', 'test.webm', await fetchFile(blob));
  await ffmpeg.run('-i', 'test.webm', 'viewpoint.mp4');
  const data = ffmpeg.FS('readFile', 'viewpoint.mp4');

  const newBlob = new Blob([data.buffer], { type: 'video/mp4' });

  // 下载
  const href = URL.createObjectURL(newBlob);
  const a = document.createElement('a');
  a.setAttribute('href', href);
  a.setAttribute('style', 'display:none');
  a.download = 'test.mp4';
  document.body.appendChild(a);
  a.click();
  a.parentNode.removeChild(a);
  URL.revokeObjectURL(href);
} 
3.1.3 遇到的问题
  1. [CreateFFmpegCore is not undefined](https://github.com/ffmpegwasm/ffmpeg.wasm/issues/199)
  2. [Cannot find module '@ffmpeg/core'](https://github.com/ffmpegwasm/ffmpeg.wasm/issues/202),提示缺少核心模块

解决方法:Why it doesn't work in my local environment,需要在初始化createFFmpeg的时候,指定corePath:

const { createFFmpeg } = FFmpeg;
const ffmpeg = createFFmpeg({
  corePath: "http://localhost:3000/public/ffmpeg-core.js",
  // Use public address if you don't want to host your own.
  // corePath: 'https://unpkg.com/@ffmpeg/core@0.10.0/dist/ffmpeg-core.js'
  log: true,
});
  1. SharedArrayBuffer is not defined in chrome 92

解决方法:

  • 卸载之前安装的,下载指定版本的包
"@ffmpeg/core": "0.8.5",
"@ffmpeg/ffmpeg": "0.9.8",
  • webpack项目修改webpack devser配置文件

create-react-app中修改config/webpackDevServer.config.js文件,这里已经执行过npm run eject暴露配置文件,没有执行过了可以通过安装@craco/craco或安装 react-app-rewired+customize-cra来修改配置文件


return {
  // 添加如下header
  headers: {
     'Cross-Origin-Embedder-Policy': 'require-corp',
     'Cross-Origin-Opener-Policy': 'same-origin',
  },
}
  1. 一顿猛如虎的操作后,最后导出的视频还是漆黑一片,使用的姿势还是不对,知道的大佬,帮我看下,哪里出错了,[手动狗头],after converting webm to mp4 using ffmpeg,video played is total black

3.2 官方提供的ffmpeg-demos--demos成功将webm转为mp4

20210803133315.jpg

3.2.1 Web Worker

Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。这里将视频转码的任务放在worker线程中运行,转码成功后通知主线程

  1. 基本使用
  • 主线程
// 创建worker线程
var worker = new Worker('work.js');

// 主线程使用worker.postMessage方法,向 Worker发消息
worker.postMessage('hello');

// 主线程使用onmessage接收子线程发回来的消息
worker.onmessage = function (event) { console.log(event.data) }
  • Worker子线程
// worker子线程内部使用全局importScripts加载其它脚本
importScripts('script1.js');

// 接收主线程的消息
this.onmessage = function(event) {}

// 向主线程发消息
this.postMessage('hello')
  1. 需要注意的:
  • Worker 线程无法读取本地文件,文件必须加载自网络
  • Worker 线程运行的脚本文件,必须与主线程的脚本文件同源
3.2.2 webm-to-mp4

我们将webm-to-mp4 source源码下载后看到:

  • processInWebWorker 初始化创建worker线程
    1. 使用new Blob创建一个类型为application/javascript的文件
    2. 文件内部使用importScripts加载ffmpeg_asm.js,postMessage发消息,onmessage监听接收消息
    3. 使用URL.createObjectURL生成映射链接
    4. new Worke创建线程
    5. URL.revokeObjectURL释放引用
import { baseURL } from '@/config';
// ffmpeg_asm.js路径
const workerPath = `${baseURL}${process.env.PUBLIC_URL}/lib/ffmpeg_asm.js`;

function processInWebWorker() {
    const blob = URL.createObjectURL(
        new Blob(
            [
                `importScripts("${workerPath}");var now = Date.now;function print(text) {postMessage({"type" : "stdout","data" : text});};onmessage = function(event) {var message = event.data;if (message.type === "command") {var Module = {print: print,printErr: print,files: message.files || [],arguments: message.arguments || [],TOTAL_MEMORY: message.TOTAL_MEMORY || false};postMessage({"type" : "start","data" : Module.arguments.join(" ")});postMessage({"type" : "stdout","data" : "Received command: " +Module.arguments.join(" ") +((Module.TOTAL_MEMORY) ? ".  Processing with " + Module.TOTAL_MEMORY + " bits." : "")});var time = now();var result = ffmpeg_run(Module);var totalTime = now() - time;postMessage({"type" : "stdout","data" : "Finished processing (took " + totalTime + "ms)"});postMessage({"type" : "done","data" : result,"time" : totalTime});}};postMessage({"type" : "ready"});`,
            ],
            {
                type: 'application/javascript',
            }
        )
    );

    const worker = new Worker(blob);
    URL.revokeObjectURL(blob);
    return worker;
}
  • convertStreams 将blob转为mp4
import { message as antdMessage } from 'antd';
let worker;
// 将上面recordRTC录制好的blob (recorder.getBlob()) 转为mp4
function convertStreams(videoBlob) {
    let aab;
    let buffersReady;
    // let workerReady;
    // let posted;

    let postMessage = function () {
        if (!worker) return;

        // 向子线程发送转码命令
        worker.postMessage({
            type: 'command',
            // ffmpeg命令参数详解
            // http://ffmpeg.org/ffmpeg-filters.html
            // https://www.jianshu.com/p/049d03705a81
            // -i 输入处理视频
            // -c:v
            // -b:v 将输出文件的视频比特率
            // -r 24 24fps帧率
            // -s 720 * 480视频大小
            arguments: '-i video.webm -c:v mpeg4 -b:v 6400k -r 24 -s 720*480 -strict experimental output.mp4'. split(' '),
            files: [
                {
                    data: new Uint8Array(aab),
                    name: 'video.webm',
                },
            ],
        });
    };

    let fileReader = new FileReader();
    fileReader.onload = function () {
        aab = this.result;
        postMessage();
    };
    // 使用FileReader读取blob
    fileReader.readAsArrayBuffer(videoBlob);

    if (!worker) {
    // 初始化worker线程
        worker = processInWebWorker();
    }
    // 接收worker子线程的消息
    worker.onmessage = function (event) {
        let message = event.data;
        if (message.type == 'ready') {
            console.log(workerPath + ' file has been loaded.');

            if (buffersReady) postMessage();
        } else if (message.type == 'stdout') {
            console.log(message.data);
        } else if (message.type == 'start') {
            console.log(workerPath + ' file received ffmpeg command.');
        } else if (message.type == 'done') {
            const key = 'updatable';
            // 转码完成
            console.log('done');
            antdMessage.destroy();
            antdMessage.success({ content: '转码成功!正在导出视频', key });

            let result = message.data[0];
            if (!message.data || !result || !result.data) {
                antdMessage.warn('转码错误,请重新操作');
                return;
            }

            let blob = new File([result.data], 'test.mp4', {
                type: 'video/mp4',
            });

            setTimeout(() => {
                // 使用RecordRTC.invokeSaveAsDialog方法导出文件
                RecordRTC.invokeSaveAsDialog(blob, 'viewpoint.mp4');
                antdMessage.success({ content: '导出成功!', duration: 2, key });
            }, 800);
        }
    };
  // 监听worker异常
  worker.onerror = function () {
    antdMessage.destroy();
    antdMessage.warn('网络异常,文件加载失败,请重试');
  };
}
3. 完整代码请移步github
4. 小结
  1. 使用到ffmpeg,需要初步了解命令参数的含义
  2. 使用到Web Worker,需要了解如何基本使用
  3. 使用到WebAssembly,后期需要自己去跟着文档编译一个wasm

4. 参考资料

  1. rrweb
  2. recordrtc
  3. navigator.mediaDevices.getDisplayMedia api
  4. ffmpeg.wasm
  5. Ffmpeg Demos
  6. Web Worker 使用教程
  7. 利用现代浏览器的强大API在浏览器中录制任意界面并实现导出、保存与管理
  8. 浏览器页面录制及转视频方案
  9. ffmpeg命令参数详解