R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。

《AWAY》音乐节直播踩坑经历

注意:本文发布于 2692 天前,文章中的一些内容可能已经过时。

还好这次音乐节有惊无险地结束了,技术部负责的直播在一开始出了一些小失误,但是还好后来没什么问题,接下来我就把这次踩坑的经历发出来,希望给以后的小伙伴提供一点经验。

往年的蛋蛋晚会都有直播,只是感觉有点 Low:一开始的直播是在页面中嵌入了一个 object,用的是 mms 协议。由于这是 Windows Media Player 的私有协议,因此只能通过 IE 打开。从第七届蛋蛋晚会开始,直播方式变成了 rtmp,由于我们没有自己的推流设备,所以就从信息中心借,推到信息中心服务器,我们自己的直播页面直接将信息中心的 Flash 嵌入即可。随着飞机墙的不断成熟,顺便在 Flash 的外面通过各种方法加上了弹幕功能,数据与飞机墙同步,通过三秒轮询的方式来不断获取信息。

这次音乐节是南航纸飞机和南航摇滚社承办的,邀请了南京各大高校的摇滚社参加,这么重要的活动当然需要有直播了。同时,由于今年三月底纸飞机 APP 的出现,以及现在移动端越来越火,是时候换成对 HTML5 友好的直播方案了。

RTMP vs HLS

RTMP 是 Flash 的直播格式,使用 rtmp 协议,是直接推一个视频流过来。RTMP 目前没有跨平台的解决方案,只能在电脑上用 Flash 播放器(或专业软件)、手机上用专门的 SDK,但是优点是延时超级小(大概三秒),适合跟观众互动的场景。

HLS 是苹果推出的一个格式,使用 http 协议,是把视频流切割成一个个小段的 .ts 文件,作为静态文件保存在服务器上,还附带了一个 .m3u8 文件,里面记录了目前大概该播放哪几段,这个文件在不断更新。.m3u8 文件的好处是,如果服务器空间不够,可以在积累了一定量的 .ts 文件后删除旧的文件,反正也不会有人看到了。缺点是延时比较高(接近半分钟),但是优点很明显:可以通过 HTTP 直接传输、可以利用 CDN 加速、可以保存直播的视频数据(只要不删 .ts 文件就行),适合发布会、晚会等大型活动的场景。而且目前手机浏览器可以在 <video> 标签里直接识别 .m3u8 格式,电脑浏览器则有 hls.js 这个库,因此这是真正跨平台的直播方案。

准备工作

今年年初在写 纸飞机视频区 的时候,发现了一个叫 DPlayer 的 HTML5 播放器,有 HLS 直播的功能(就是使用了 hls.js),还支持弹幕(截至目前,官方还不支持直播弹幕),因此就打算用 DPlayer 来做直播。那么难点有以下几个:

  1. 学校的推流设备只能推 RTMP 流;
  2. DPlayer 的 API 与我们之前的不兼容,且不支持直播弹幕;
  3. 我们对晚会现场的网络情况不了解,且只有晚会开始当天的下午才能过去调试;
  4. 我们决定用 my.nuaa.edu.cn 的域名进行直播,然而域名所有权是学校,我们没法修改 CNAME,而且服务器的外网带宽非常差,不能用反向代理,因此无法使用七牛等第三方直播服务。

对于第一点,稍加搜索就可以找到一个叫 nginx-rtmp-module 的东西,可以接收 RTMP 流,并且生成 HLS 的文件;第二点比较简单,只需要仿照 DPlayer 的 API 写一套自己的就行了;第三点,只能确保在大部分情况下都能成功,然后晚会当天下午早点过去,通过不断减少码率来测试上传效率;第四点是最麻烦的,只能用我们自己的服务器了,然而还是不方便,因为我们的服务器内网只有 22 和 80 两个端口,外网只有 80 端口,因此无法直接推流。我们决定在自己的电脑上面搭一个虚拟机环境(虚拟机方便移植),然后用纸飞机服务器做 HLS 的反向代理。

nginx-rtmp-module

这是 GitHub 上面某个大神写的 Nginx 的插件。因此可以尝试一下。编译的过程挺简单的,按照 README 来做就可以,缺什么包就装什么包。装完之后按照 README 来写配置文件就可以了。在这里感谢一下 @SummerZhang 大神的助攻。

# /usr/local/nginx/conf/nginx.conf
rtmp {
    server {
        listen 1935;
        chunk_size 4000;
        # 推流推到 rtmp://ip:1935/hls/away
        application hls {
            live on;
            # HLS 相关
            hls on;
            # m3u8 和 ts 文件存储的路径
            hls_path /tmp/hls;
            # 每个 ts 文件的长度
            hls_fragment 2s;
            # 一个 m3u8 中列出的文件的总长度
            hls_playlist_length 10s;
            # 使用 FFmpeg 重新编码 by @SummerZhang
            exec ffmpeg -re -i rtmp://localhost:1935/$app/$name -c:v libx264 -profile:v main -preset fast -crf 20 -minrate 800k -maxrate 3500k -tune zerolatency -movflags +faststart -c:a aac -b:a 160k -strict -2 -f flv rtmp://localhost:1935/$app/$name;
            # 录像可以用这些指令
            # record all;
            # record_path /path/to/record;
            # record_unique on;
        }
    }
}
http {
    server {
        listen 8080;
        location /hls {
            # 设置文件的 Content-type
            types {
                application/vnd.apple.mpegurl m3u8;
                video/mp2t ts;
            }
            # 访问 http://ip:8080/hls/away.m3u8 即可
            root /tmp;
            add_header Cache-Control no-cache;
        }
    }
}

将 Nginx 启动起来,我们就有自己的推流+转码的服务器啦!用 OBS 推流试了试,我电脑上的 osu! 界面很流畅地通过另一台电脑的 Flash 播放器放了出来。

DPlayer 的改造

此处是不是应该 @DIYgod?

DPlayer 原生的后端是用 Node.js 写的,然而纸飞机用的是 PHP,因此我们在之前的视频区里仿照着原来的格式写了自己的一套 API。然而对于直播弹幕,由于 DPlayer 并没有实现,因此需要我们自己写。大概分为两步:

  1. 将直播的弹幕与普通视频的弹幕分开;
  2. 仿照 DPlayer 原生 API 的格式写一套支持轮询的直播弹幕 API。

DPlayer 使用了 ES6,因此需要先从 GitHub 下载源码,然后 npm install。打开 src/DPlayer.js 之后,找到 AJAX 获取弹幕的部分,发现了这两句:

this.danIndex = 0;
this.dan = response.danmaku.sort((a, b) => a.time - b.time);

于是去搜索 this.dan,将所有控制弹幕加载和显示的地方外面都包上一层 if (!this.option.video.url.match(/(m3u8)$/i)),这样如果是直播的话,就不会显示和加载任何弹幕了。然后看到这个地方:

let item = this.dan[this.danIndex];
while (item && this.video.currentTime >= parseFloat(item.time)) {
    danmakuIn(item.text, item.color, item.type);
    item = this.dan[++this.danIndex];
}

所以是监视了 <video> 的播放事件,利用 danmakuIn 函数在屏幕上显示弹幕的。于是在弹幕加载的地方修改一下:

if (this.option.video.url.match(/(m3u8)$/i) && Hls.isSupported()) {
    if (this.option.autoplay && !isMobile) {
        this.play();
    }
    else if (isMobile) {
        this.pause();
    }
    let lastId = 0;
    const self = this;
    // 三秒轮询加载弹幕
    setInterval(function () {
        const xhr = new XMLHttpRequest();
        xhr.onreadystatechange = () => {
            if (xhr.readyState === 4) {
                if (xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) {
                    const response = JSON.parse(xhr.responseText);
                    if (response.code !== 1) {
                        alert(response.msg);
                    }
                    else {
                        self.dan = response.danmaku.sort((a, b) => a.id - b.id);
                        self.element.getElementsByClassName('dplayer-danloading')[0].style.display = 'none';
                        if (self.dan.length > 0) {
                            const newId = parseInt(self.dan[self.dan.length - 1].id);
                            if (lastId < newId) {
                                lastId = newId;
                            }
                            let danIndex = 0;
                            // 确保在三秒内均匀显示出本次加载的内容
                            setInterval(function () {
                                if (danIndex < self.dan.length) {
                                    const t = self.dan[danIndex];
                                    danIndex++;
                                    if (t) {
                                        danmakuIn(htmlEncode(t.text), t.color, t.type);
                                    }
                                }
                            }, parseInt(3000 / self.dan.length));
                        }
                    }
                }
                else {
                    console.log('Request was unsuccessful: ' + xhr.status);
                }
            }
        };
        let apiurl = `${self.option.danmaku.api}&player=${self.option.danmaku.id}&lastId=${lastId}`;
        if (self.option.danmaku.maximum) {
            apiurl += `&max=${self.option.danmaku.maximum}`;
        }
        xhr.open('get', apiurl, true);
        xhr.send(null);
    }, 3000);
} else {
    // 这里放之前加载弹幕的代码
}

于是 Live Danmaku 的问题就解决啦!

此外,为了适应手机端,我又对 DPlayer 的前端做了一些修改。

设备调试

推流设备到了,但是我们还没有摄像机,因此就将自己的电脑通过 HDMI 连接到设备上,将设备与本机连到同一网段,设置好设备的推流地址为本机 IP,然后通过 ssh -gL 1935:localhost:1935 root@虚拟机ip 将到达本机 1935 端口的请求转发到虚拟机的 Nginx 中,然后开始推流!然而发现,使用 jwPlayer 等 Flash 写的播放器可以很流畅地播放生成的 HLS 视频,但是使用 DPlayer、手机播放器、本机的 VLC 都没法正常地播放,视频播放非常慢,音频一直卡顿。搜索了好久,感觉可能是设备推的流的 DTS 有问题,然而我们尝试用 FFmpeg 重新编码都没能解决这个问题。这个问题拖了我好几天。

然而在晚会当天早上,我起床的时候脑子激灵了一下,突然想到可以用设备向本机推流,然后用 jwPlayer 播放,然后用 OBS 录屏,再通过 RTMP 推到另一个流上,页面上访问对应的 HLS 就可以了!于是搞了一早上,成功地把我的电脑屏幕通过 直播设备 -> 本机 Nginx -> jwPlayer -> 另一个 Nginx -> DPlayer 播放到了另一个电脑上。最后从我的电脑向纸飞机服务器建立一个反向隧道,然后纸飞机服务器上的 Nginx 通过这个隧道做一个反向代理就可以了。

晚会当天下午我有事情,所以没有陪技术部的其他同学调试,而是远程指导。大部分问题都解决了,但是联网的问题还是解决不了。晚会现场只能从隔壁办公室接一根网线(幸亏是校园网)过来,而且不能用路由器,同样的网线接电脑可以上网,接路由器就获取不到 IP(两个路由器都不行),设置静态 IP 也不管用,不知道为啥。但是最后我们想到了一个方案:设备通过网线接到路由器,电脑通过 WiFi 接到路由器,然后电脑接外网的网线,各种配置之后,直播页面上终于显示出了摄像机的画面。

后续

由于时间紧促,这次还有一些 BUG,例如没能测出上传速度,导致下载速度超级慢,就算用校园网也会非常卡,后来重新用 OBS 调整了输出质量才解决这个问题。当然,这次直播只是一次尝试,相信蛋蛋晚会的直播会比这次更成熟。

Disqus 加载中……如未能加载,请将 disqus.com 和 disquscdn.com 加入白名单。

这是我们共同度过的

第 3070 天