R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。 MUGer, hacker, developer, amateur UI designer, punster, Japanese learner.

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

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

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

往年的蛋蛋晚会都有直播,只是感觉有点 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 [email protected]虚拟机ip 将到达本机 1935 端口的请求转发到虚拟机的 Nginx 中,然后开始推流!然而发现,使用 jwPlayer 等 Flash 写的播放器可以很流畅地播放生成的 HLS 视频,但是使用 DPlayer、手机播放器、本机的 VLC 都没法正常地播放,视频播放非常慢,音频一直卡顿。搜索了好久,感觉可能是设备推的流的 DTS 有问题,然而我们尝试用 FFmpeg 重新编码都没能解决这个问题。这个问题拖了我好几天。

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

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

后续

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

版权声明:除文章开头有特殊声明的情况外,所有文章均可在遵从 CC BY 4.0 协议的情况下转载。
上一篇: 2016 全国大学生网络安全邀请赛暨第二届上海市大学生网络安全大赛决赛酱油记
下一篇: Rex 的除夕夜加密红包 Writeup

这是我们共同度过的

第 1591 天