还好这次音乐节有惊无险地结束了,技术部负责的直播在一开始出了一些小失误,但是还好后来没什么问题,接下来我就把这次踩坑的经历发出来,希望给以后的小伙伴提供一点经验。
往年的蛋蛋晚会都有直播,只是感觉有点 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 来做直播。那么难点有以下几个:
- 学校的推流设备只能推 RTMP 流;
- DPlayer 的 API 与我们之前的不兼容,且不支持直播弹幕;
- 我们对晚会现场的网络情况不了解,且只有晚会开始当天的下午才能过去调试;
- 我们决定用 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 并没有实现,因此需要我们自己写。大概分为两步:
- 将直播的弹幕与普通视频的弹幕分开;
- 仿照 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 调整了输出质量才解决这个问题。当然,这次直播只是一次尝试,相信蛋蛋晚会的直播会比这次更成熟。