04 | 可以把采集到的音视频数据录制下来吗?

在音视频会议、在线教育等系统中,录制是一个特别重要的功能。尤其是在线教育系统,基本上每一节课都要录制下来,以便学生可以随时回顾之前学习的内容。

实现录制功能有很多方式,一般分为服务端录制客户端录制,具体使用哪种方式还要结合你自己的业务特点来选择。

  • 服务端录制:优点是不用担心客户因自身电脑问题造成录制失败(如磁盘空间不足),也不会因录制时抢占资源(CPU占用率过高)而导致其他应用出现问题等;缺点是实现的复杂度很高。不过由于本文要重点讲解的是接下来的客户端录制,所以这里我就不再深入展开讲解了,你只需要知道有服务端录制这回事就行了,或者如果你感兴趣,也可以自行搜索学习。

  • 客户端录制:优点是方便录制方(如老师)操控,并且所录制的视频清晰度高,实现相对简单。这里可以和服务端录制做个对比,一般客户端摄像头的分辨率都非常高的(如1280x720),所以客户端录制可以录制出非常清晰的视频;但服务端录制要做到这点就很困难了,本地高清的视频在上传服务端时由于网络带宽不足,视频的分辨率很有可能会被自动缩小到了640x360,这就导致用户回看时视频特别模糊,用户体验差。不过客户端录制也有很明显的缺点,其中最主要的缺点就是录制失败率高。因为客户端在进行录制时会开启第二路编码器,这样会特别耗CPU。而CPU占用过高后,就很容易造成应用程序卡死。除此之外,它对内存、硬盘的要求也特别高。

这里需要再次说明的是,由于本专栏的文章是聚焦在 WebRTC 的使用上,所以我们只讲如何使用 WebRTC 库实现客户端录制音视频流。至于服务端录制的相关知识,由于与 WebRTC库无关,所以我们就不做进一步讲解了。

在WebRTC处理过程中的位置

在正式进入主题之前,咱们依旧先来看一下本文在 WebRTC 处理过程中的位置。如下图所示:

WebRTC 1对1音视频实时通话过程示意图

通过上图可以了解到,咱们本文所讲的内容就是音视频采集下面的录制功能

基本原理

录制的基本原理还是蛮简单的,但要做好却很不容易,主要有以下三个重要的问题需要你搞清楚。

第一个,录制后音视频流的存储格式是什么呢?比如,是直接录制原始数据,还是录制成某种多媒体格式(如 MP4 )?你可能会想,为什么要考虑存储格式问题呢?直接选择一个不就好了?但其实存储格式的选择对于录制后的回放至关重要,这里我先留个悬念不细说,等看到后面的内容你自然就会理解它的重要性与局限性了。

第二个,录制下来的音视频流如何播放?是使用普通的播放器播放,还是使用私有播放器播呢?其实,这与你的业务息息相关。如果你的业务是多人互动类型,且回放时也要和直播时一样,那么你就必须使用私有播放器,因为普通播放器是不支持同时播放多路视频的。还有,如果你想让浏览器也能观看回放,那么就需要提供网页播放器。

第三个,启动录制后多久可以回放呢?这个问题又分为以下三种情况。

  • 边录边看,即开始录制几分钟之后用户就可以观看了。比如,我们观看一些重大体育赛事时(如世界杯),一般都是正式开始一小段时间之后观众才能看到,以确保发生突发事件时可以做紧急处理。
  • 录制完立即回放,即当录制结束后,用户就可以回看录制了。比较常见的是一些技术比较好的教育公司的直播课,录制完成后就可以直接看回放了。
  • 录完后过一段时间可观看。大多数的直播系统都是录制完成后过一段时间才可以看回放,因为录制完成后还要对音视频做一些剪辑、转码,制作各种不同的清晰度的回放等等。

接下来,咱们就针对上面的问题详细分析一下。我们先从存储格式的重要性和局限性切入,也正好解答下前面所留的悬念。

录制原始数据的优点是不用做过多的业务逻辑,来什么数据就录制什么数据,这样录制效率高,不容易出错;并且录制方法也很简单,可以将音频数据与视频数据分别存放到不同的二进制文件中。文件中的每一块数据可以由下面的结构体描述:

struct data 
    int media_type; // 数据类型,0: 音频 1: 视频
    int64_t ts;     // timestamp,记录数据收到的时间
    int data_size;  // 数据大小
    char* data;     // 指定具体的数据
}media_data;

当录制结束后,再将录制好的音视频二进制文件整合成某种多媒体文件,如FLV、MP4等。

但它的弊端是,录制完成后用户要等待一段时间才能看到录制的视频。因为在录制完成后,它还要做音视频合流、输出多媒体文件等工作。

那直接录制成某种多媒体格式会怎么样呢?如果你对多媒体文件格式非常熟悉的话,应该知道 FLV 格式特别适合处理这种流式数据。因为FLV媒体文件本身就是流式的,你可以在FLV文件的任何位置进行读写操作,它都可以正常被处理。因此,如果你使用 FLV 的话,就不用像前面录制原始数据那样先将二制数据存储下来,之后再进行合流、转码那么麻烦了。

不仅如此,采用 FLV 媒体格式进行录制,你还可以再进一步优化,将直播的视频按 N 分钟为单位,录制成一段一段的 FLV,然后录完一段播一段,这样就实现了上面所讲的边录边看的效果了。

但FLV也不是万能的。如果你的场景比较复杂(如多人互动的场景),即同时存在多路视频,FLV 格式就无法满足你的需求了,因为 FLV 只能同时存在一路视频和一路音频,而不能同时存在多路视频这种情况。此时,最好的方法还是录制原始数据,然后通过实现私有播放器来达到用户的需求。

当然,即使是单视频的情况下,FLV的方案看上去很完美,但实际情况也不一定像你想象的那样美好。因为将音视频流存成 FLV 文件的前提条件是音视频流是按顺序来的,而实际上,音视频数据经过 UDP 这种不可靠传输网络时,是无法保证音视频数据到达的先后顺序的。因此,在处理音视频录制时,你不仅要考虑录制的事情,还要自己做音视频数据排序的工作。除此之外,还有很多其他的工作需要处理,这里我就不一一列举了。

不过,好在 WebRTC 已经处理好了一切。有了 WebRTC, 你不用再考虑将音视频保存成什么媒体格式的问题;有了 WebRTC ,你不用再考虑网络丢包的问题;有了WebRTC,你不用再考虑音视频数据乱序的问题……这一系列恼人的问题,WebRTC 都帮你搞定了。下面就让我们赶紧应用起来,看一下 WebRTC 是如何进行客户端录制的吧!

基础知识

为便于你更好地理解,在学习如何使用 WebRTC 实现客户端录制之前,你还需要先了解一些基础知识。

在 JavaScript 中,有很多用于存储二进制数据的类型,这些类型包括:ArrayBuffer、ArrayBufferView和Blob。那这三者与我们今天要讲的录制音视频流有什么关系呢?

WebRTC 录制音视频流之后,最终是通过 Blob 对象将数据保存成多媒体文件的;而 Blob 又与ArrayBuffer有着很密切的关系。那ArryaBuffer 与 ArrayBufferView 又有什么联系呢?接下来,我们就了解一下这3种二进制数据类型,以及它们之间的关系吧。

1. ArrayBuffer

ArrayBuffer 对象表示通用的、固定长度的二进制数据缓冲区。因此,你可以直接使用它存储图片、视频等内容。

但你并不能直接对 ArrayBuffer 对象进行访问,类似于 Java 语言中的抽象类,在物理内存中并不存在这样一个对象,必须使用其封装类进行实例化后才能进行访问。

也就是说, ArrayBuffer 只是描述有这样一块空间可以用来存放二进制数据,但在计算机的内存中并没有真正地为其分配空间。只有当具体类型化后,它才真正地存在于内存中。如下所示:

let buffer = new ArrayBuffer(16); // 创建一个长度为 16 的 buffer
let view = new Uint32Array(buffer);

let buffer = new Uint8Array([255, 255, 255, 255]).buffer;
let dataView = new DataView(buffer);

在上面的例子中,一开始生成的buffer是不能被直接访问的。只有将buffer做为参数生成一个具体的类型的新对象时(如 Uint32Array 或 DataView),这个新生成的对象才能被访问。

2. ArrayBufferView

ArrayBufferView 并不是一个具体的类型,而是代表不同类型的Array的描述。这些类型包括:Int8Array、Uint8Array、DataView等。也就是说Int8Array、Uint8Array等才是JavaScript在内存中真正可以分配的对象。

以 Int8Array 为例,当你对其实例化时,计算机就会在内存中为其分配一块空间,在该空间中的每一个元素都是8位的整数。再以Uint8Array为例,它表达的是在内存中分配一块每个元素大小为8位的无符号整数的空间。

通过这上面的描述,你现在应该知道 ArrayBuffer 与 ArrayBufferView 的区别了吧?ArrayBufferView 指的是 Int8Array、Uint8Array、DataView等类型的总称,而这些类型都是使用 ArrayBuffer 类实现的,因此才统称他们为 ArrayBufferView。

3. Blob

Blob(Binary Large Object)是 JavaScript 的大型二进制对象类型,WebRTC 最终就是使用它将录制好的音视频流保存成多媒体文件的。而它的底层是由上面所讲的 ArrayBuffer 对象的封装类实现的,即 Int8Array、Uint8Array等类型。

Blob 对象的格式如下:

var aBlob = new Blob( array, options );

其中,array 可以是ArrayBuffer、ArrayBufferView、Blob、DOMString等类型 ;option,用于指定存储成的媒体类型。

介绍完了这几个你需要了解的基本概念之后,接下来,我们书归正传,看看如何录制本地音视频。

如何录制本地音视频

WebRTC 为我们提供了一个非常方便的类,即 MediaRecorder。创建 MediaRecorder 对象的格式如下:

var mediaRecorder = new MediaRecorder(stream[, options]);

其参数含义如下:

  • stream,通过 getUserMedia 获取的本地视频流或通过 RTCPeerConnection 获取的远程视频流。
  • options,可选项,指定视频格式、编解码器、码率等相关信息,如 mimeType: 'video/webm;codecs=vp8'

MediaRecorder 对象还有一个特别重要的事件,即 ondataavailable 事件。当MediaRecoder捕获到数据时就会触发该事件。通过它,我们才能将音视频数据录制下来。

有了上面这些知识,接下来,我们看一下具体该如何使用上面的对象来录制音视频流吧!

1. 录制音视频流

首先是获取本地音视频流。在《01 | 原来通过浏览器访问摄像头这么容易》一文中,我已经讲过如何通过浏览器采集音视频数据,具体就是调用浏览器中的 getUserMedia 方法。所以,这里我就不再赘述了。

获取到音视频流后,你可以将该流当作参数传给 MediaRecorder 对象,并实现 ondataavailable 事件,最终将音视频流录制下来。具体代码如下所示,我们先看一下 HTML 部分:

<html>
...
<body>
    ...
    <button id="record">Start Record</button>
    <button id="recplay" disabled>Play</button>
    <button id="download" disabled>Download</button>
    ...
</body>
</html>

上面的 HTML 代码片段定义了三个 button,一个用于开启录制,一个用于播放录制下来的内容,最后一个用于将录制的视频下载下来。然后我们再来看一下 JavaScript 控制部分的代码:

...

var buffer;

...

//当该函数被触发后,将数据压入到blob中
function handleDataAvailable(e){
        if(e && e.data && e.data.size > 0){
                buffer.push(e.data);
        }
}

function startRecord(){

        buffer = [];

        //设置录制下来的多媒体格式 
        var options = {
                mimeType: 'video/webm;codecs=vp8'
        }

        //判断浏览器是否支持录制
        if(!MediaRecorder.isTypeSupported(options.mimeType)){
                console.error(`${options.mimeType} is not supported!`);
                return;
        }

        try{
                //创建录制对象
                mediaRecorder = new MediaRecorder(window.stream, options);
        }catch(e){
                console.error('Failed to create MediaRecorder:', e);
                return;
        }

        //当有音视频数据来了之后触发该事件
        mediaRecorder.ondataavailable = handleDataAvailable;
        //开始录制
        mediaRecorder.start(10);
}

...

当你点击 Record 按钮的时候,就会调用 startRecord 函数。在该函数中首先判断浏览器是否支持指定的多媒体格式,如 webm。 如果支持的话,再创建
MediaRecorder 对象,将音视频流录制成指定的媒体格式文件。

实际存储时,是通过 ondataavailable 事件操作的。每当ondataavailable事件触发时,就会调用handleDataAvailable函数。该函数的实现就特别简单了,直接将数据 push 到 buffer 中,实际在浏览器底层使用的是Blob对象。

另外,在开启录制时,可以设置一个毫秒级的时间片,这样录制的媒体数据会按照你设置的值分割成一个个单独的区块,否则默认的方式是录制一个非常大的整块内容。分成一块一块的区块会提高效率和可靠性,如果是一整块数据,随着时间的推移,数据块越来越大,读写效率就会变差,而且增加了写入文件的失败率。

2. 回放录制文件

通过上面的方法录制好内容后,又该如何进行回放呢?让我们来看一下代码吧!

在 HTML 中增加下面的代码:

...
<video id="recvideo"></video>
...

在HTML中只增加了一个 <video>标签,用于播放录制的内容。下面的 JavaScript 是将录制内容与<video>标签联接到一起:

 var blob = new Blob(buffer, {type: 'video/webm'});
 recvideo.src = window.URL.createObjectURL(blob);
 recvideo.srcObject = null;
 recvideo.controls = true;
 recvideo.play();

在上面的代码中,首先根据 buffer 生成 Blob 对象;然后,根据 Blob 对象生成 URL,并通过 <video> 标签将录制的内容播放出来了。

3. 下载录制好的文件

那如何将录制好的视频文件下载下来呢?代码如下:

btnDownload.onclick = ()=> {
        var blob = new Blob(buffer, {type: 'video/webm'});
        var url = window.URL.createObjectURL(blob);
        var a = document.createElement('a');

        a.href = url;
        a.style.display = 'none';
        a.download = 'aaa.webm';
        a.click();
}

将录制好的视频下载下来还是比较简单的,点击 download 按钮后,就会调用上面的代码。在该代码中,也是先创建一个 Blob 对象,并根据 Blob 对象创建 URL;然后再创建一个 <A> 标签,设置 A 标签的 href 和 download属性。这样当用户点击该标签之后,录制好的文件就下载下来了。

小结

在直播系统中,一般会包括服务端录制和客户端录制,它们各有优劣,因此好点的直播系统会同时支持客户端录制与服务端录制。

通过上面的介绍,你应该已经了解了在浏览器下如何录制音视频流了,你只需要使用浏览器中的 MediaRecorder 对象,就可以很方便地录制本地或远程音视频流。

今天我们介绍的内容只能够实现一路视频和一路音视流的情况,但在实际的项目中还要考虑多路音视频流的情况,如有多人同时进行音视频互动的情况,在这种复杂场景下该如何将它们录制下来呢?即使录制下来又该如何进行回放呢?

所以,对于大多数采用客户端录制方案的公司,在录制时一般不是录制音视频数据,而是录制桌面加音频,这样就大大简化了客户端实现录制的复杂度了。我想你也一定知道这是为什么,没错就是因为在桌面上有其他参与人的视频,所以只要将桌面录制下来,所有的视频就一同被录制下来了哈!

思考时间

上面我已经介绍了很多录制音视频可能出现的问题。现在你思考一下,是否可以将多路音视频录制到同一个多媒体文件中呢?例如在多人的实时直播中,有三个人同时共享视频,是否可以将这三个人的视频写入到一个多媒体文件中呢(如 MP4)?这样的MP4在播放时会有什么问题吗?

欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

所做Demo的GitHub链接(有需要可以点这里)

精选留言

  • 耳东

    2019-07-23 15:33:55

    React版示例代码:
    https://github.com/baayso/react-tic-tac-toe/commits/master/src/components/Video/Video.js
    作者回复

    给你个大大的赞!

    2019-07-23 18:31:53

  • xiao豪

    2019-07-23 15:10:52

    示例代码

    var buffer;
    // 创建录制对象
    var mediaRecorder;

    // 当该函数被触发后,将数据压入到 blob 中
    function handleDataAvailable(e){
    if(e && e.data && e.data.size > 0){
    buffer.push(e.data);
    }
    }

    function startRecord(){
    // 防止多次启动报错
    if(mediaRecorder && mediaRecorder.state === "recording"){
    return;
    }

    buffer = [];

    // 设置录制下来的多媒体格式
    var options = {
    mimeType: 'video/webm;codecs=vp8'
    }

    // 判断浏览器是否支持录制
    if(!MediaRecorder.isTypeSupported(options.mimeType)){
    console.error(`${options.mimeType} is not supported!`);
    return;
    }

    try{
    mediaRecorder = new MediaRecorder(videoplay.srcObject, options);
    }catch(e){
    console.error('Failed to create MediaRecorder:', e);
    return;
    }

    // 当有音视频数据来了之后触发该事件
    mediaRecorder.ondataavailable = handleDataAvailable;
    // 开始录制
    mediaRecorder.start(10);
    }

    function startRecplay(){
    console.log(mediaRecorder)
    var blob = new Blob(buffer, {type: 'video/webm'});
    recvideo.src = window.URL.createObjectURL(blob);
    recvideo.srcObject = null;
    recvideo.controls = true;
    recvideo.play();
    }

    function download(){
    var blob = new Blob(buffer, {type: 'video/webm'});
    var url = window.URL.createObjectURL(blob);
    var a = document.createElement('a');

    a.href = url;
    a.style.display = 'none';
    a.download = 'aaa.webm';
    a.click();
    a.remove();
    }

    function stop(){
    if(mediaRecorder && (mediaRecorder.state === "recording" || mediaRecorder.state === "paused")){
    mediaRecorder.stop();
    }
    }
    作者回复

    非常棒!

    2019-07-23 18:32:12

  • 恋着歌

    2019-07-24 17:28:46

    交作业 https://codepen.io/htkar/pen/OKMbzV
    作者回复

    不错! 不错!

    2019-07-24 23:11:43

  • 小樱桃

    2021-04-23 08:28:01

    在浏览器里使用了录制,录制本地视频是正常的,可是录制远程视频却只录制了声音,视频只有一帧静态图不知道为什么😣希望老师看到能解答一下
    作者回复

    每个recoder 只能录制一路视频,你需要用两个recoder 分别录制,之后用ffmpeg 合成

    2021-05-01 20:05:57

  • 老猴

    2020-11-19 12:44:45

    录下来的数据推到了buffer中,这个buffer有什么限制?最大能录多大数据量的数据?时间长了,会不会出现异常,或者让浏览器甚至机器变慢的问题?
    作者回复

    这个没有明确说明,时间长了一定会有影响。但在启动录制时可以设置一个参数。在例子中我设置的是10ms。到达后就会触发事件,然后你就可以将录制的数据保存起来了。mediaRecorder.start(10)中参数含义如下:“开始录制媒体,这个方法调用时可以通过给timeslice参数设置一个毫秒值,如果设置这个毫秒值,那么录制的媒体会按照你设置的值进行分割成一个个单独的区块, 而不是以默认的方式录制一个非常大的整块内容.”

    2020-11-19 14:37:45

  • hao11111205

    2019-08-01 10:13:29

    老师,在谷歌浏览器点击下载,不出现保存窗口,直接下载。这是为什么?
    作者回复

    如果你之前在浏览器中有设置保存文件的默认路径就会这样子

    2019-08-01 14:08:43

  • 李跃爱学习

    2019-07-23 14:17:08

    从产品的角度来讲, 应该是设置保存路径,开始录制,结束录制这么操作。
    但是看老师介绍的是要点击保存,就将当前blob的内容保存下来, 真实场景中,要录制很长时间,内存肯定是放不下吧,所以有点困惑,录制开始后是写到磁盘了,还是保存在内存中呢?
    作者回复

    我看一下 handleDataAvailable 这个函数,在这个函数中我是直接将它保存到内存中了。如果你想保存成文,可以直接在这里修改代码!

    2019-07-23 18:27:01

  • Leeing

    2022-03-31 01:38:03

    思考题想了三种方案,不知道对不对
    一、通过多个mediarecorder实例将多人的音视频流录制成多份音视频文件,然后通过ffmpeg合并,但是如何保证多份音视频文件正确播放的时间戳呢?
    二、通过记录多人的音视频二进制数据,然后进行合流、转码,但是问题就更多了,视频怎么合流、音频怎么合流、丢包怎么处理、顺序怎么保障(设置缓冲区进行重排?)
    三、将多个音视频流渲染到各自的canvas上,再将canvas合并,最后将合并后的canvas下载下来
  • 剑衣清风

    2019-07-23 10:41:14

    github 里的代码可以运行么?

    关于这讲,推荐看看这个 https://cloud.tencent.com/developer/article/1366886 里的MediaRecorder使用示例 - 摄像头版,或者直接打开 https://wendychengc.github.io/media-recorder-video-canvas/cameracanvas.html,看看源文件的相关代码即可。
    作者回复

    可以运行,你再试一下

    2019-07-24 23:12:27

  • lvxus

    2019-07-23 09:09:49

    老师,想请问一下录制桌面具体是什么操作
    作者回复

    后面的课程有讲,别着急!

    2019-07-23 15:02:13

  • ifelse

    2025-07-09 12:39:28

    学习打卡
  • Geek_03197d

    2025-06-09 22:07:03

    老师,你好,请问一下,连续点击两次record按钮,再点击play会报错是什么原因呢?
  • 里脊

    2025-01-24 09:53:17

    老师好,你说的录制一路视频指的是 new MediaRecorder 的第一个参数就已经固定好了,然后一个 recorder 实例对应一个 buffer 是吗
  • 张伟

    2022-10-25 17:36:45

    浏览器调用的摄像头中的stream数据是原始视频数据对吧?也就是yuv这种数据,如果要发送到后端进行处理的话,为什么mediaStream没有提供监听流的事件,用于上报,还需要使用recorder来进行监听呢?recorder监听有个好处是可以将原始的数据进行编码,我这样理解对吗?
  • 行则将至

    2022-06-14 12:26:57

    那 ArryaBuffer 与 ArrayBufferView 又有什么联系呢?

    ArrayBuffer 单词拼写错误
  • Leeing

    2022-03-31 01:53:01

    又想到一个方案,通过getdisplaymedia的屏幕共享流录制整个页面
  • 冤大头

    2022-03-28 19:48:36

    var blob = new Blob(buffer, {type: 'video/webm'}); recvideo.src = window.URL.createObjectURL(blob); recvideo.srcObject = null;


    recvideo.srcObject 这个属性是什么意思
  • 最后的风之子

    2021-10-15 07:25:34

    老师 我想给我们公司的NVR做一个无插件网页客户端 可以同时预览16路视频 咱们这个专栏讲的技术 可以实现吗
  • 仰泳鱼

    2021-10-09 10:24:37

    老师你好。请问这的客户端录制(除了录制桌面),在1V1的情况下(也就是webrtc有2路视频了对吧),那是录的自己 还是录的对方, 如果支持录2录是否 要 recoder1 recoder2 都来一下。
  • 云仔糕

    2021-09-07 09:09:09

    老师,那可不可以先由客户端录制,客户端都录制好了,再上传到服务器呢?