多媒体开发(3):直播

之前介绍了如何录制音视频,以及相关的多媒体的概念。对于已经录制的多媒体进行“就地”播放(参考前文),就是回放,除了“回放”这个流程,还有一个流程也会经常遇到,那就是“直播”。

本文介绍直播的实现。

“直播”的特点是边录制边播放。如果想完成直播的流程,一般需要有支持直播功能的服务器(也叫流媒体服务器)。有了直播服务器后,就可以把录制的数据推送到服务器,然后再从服务器拉取数据进行播放。

那么怎么实现这个有直播功能的服务器呢,在这里,小程介绍具备这个功能的服务器程序:nginx。

nginx是一个http服务器,但通过扩展(比如加入rtmp模块等),可以变身为流媒体服务器,并且支持rtmp与hls协议,也就具备了“直播”的功能。如果你对于rtmp或hls协议不了解,也没有关系,只需要知道它是一个传输的约定就可以了,在特定的场景再作深入了解。

nginx是一个完整的程序,你只需要做一些安装与配置的工作,就可以弄出一个支持直播(或点播)的原型出来,甚至可以投入使用。

(一)安装nginx

以编译nginx源码的方式来安装nginx,因为要让它支持rtmp模块,当然你也可以通过brew install来安装,但不是我这里介绍的方式。

我列一些具体的安装操作,你可以按需参考:

(1)nginx源码下载

到nginx官网下载最新版本的nginx源码,官网的地址://nginx.org/en/download.html

(2)rtmp模块

也就是nginx-rtmp-module的源码下载,让它跟nginx项目在同一个目录下面:

git clone //github.com/arut/nginx-rtmp-module.git

(3)openssl模块

openssl被rtmp使用,需要下载到它的源码。在 //www.openssl.org/source/ 中找到它某个版本来下载,比如我下载的是openssl-1.1.1i,下载解压后与nginx项目在一个目录下面。
这时,nginx、rtmp跟openssl的源码都下载到了,如下面的目录结构:
nginx源码目录结构

(4)编译nginx并安装

下载完源码后,就可以开始编译了。注意:如果之前用brew安装过nginx,那要先卸载:sudo brew uninstall nginx。

cd nginx-1.19.6
./configure --add-module=../nginx-rtmp-module --with-openssl=../openssl-1.1.1i --without-http_rewrite_module
make
sudo make install

最终的安装目录是/usr/local/nginx/sbin/,在那里可以看到nginx执行文件,为了让shell(比如我在用的bash或sh)能搜索到这个目录,在配置文件/.bash_profile,或/.zhrc中指定搜索路径,增加下面这句:

export PATH=”${PATH}:/usr/local/nginx/sbin/”

再让这个配置生效:

source ~/.bash_profile 或:
source ~/.zshrc

这时,可以直接在shell中使用nginx命令了。

查看nginx配置文件路径等信息:

nginx -h

启动nginx:

sudo nginx

如果有提示端口已经被占用,那可能已经启动了,可以重新启动:

sudo nginx -s reload

测试nginx:

curl 127.0.0.1  
或者浏览器访问 localhost
能看到welcome信息即表示安装成功而且已经运行,占用8080端口。

mac上的hosts文件是/etc/hosts,如果需要修改可以这样进行:sudo vi /etc/hosts

最终可以看到nginx的welcome:
nginx返回的welcome页面

(二)实现直播

查看配置文件的路径:

nginx -h

配置文件为/usr/local/nginx/conf/nginx.conf,也可以通过nginx -c来指定一个新的配置文件。

在配置文件中(比如最末尾),增加rtmp项:

rtmp {
	server {
		listen 1935;    	# port
		chunk_size 4096;    # data chunk size
		application rtmpdemo {
			live on;
		}
	}
}

1935为端口,chunk_size为块大小。rtmpdemo是应用名称,可以随意改。

注意,如果担心配置修改有语法上的错误,可以这样检测:

sudo nginx -t

配置完后,重启nginx:

sudo nginx -s reload

用ffmpeg来模拟推流:

sudo ffmpeg -re -i 1.mp4 -vcodec copy -f flv rtmp://localhost/rtmpdemo/test1

其中,-re 表示按帧率来推;-f 为推送时封装的格式,对于rtmp都应该使用flv。1.mp4是当前目录的一个视频文件。

这时,服务器nginx已经有多媒体流了,客户端拉流播放:

ffplay “rtmp://localhost/rtmpdemo/test1 live=1”

上面的演示,是把一个本地的文件推到了nginx,实际的直播场景中,是边录制边推流,你可以结合之前介绍的录制视频的办法,来做到录制。

至此,已经把“使用nginx来实现直播”的主体操作介绍完了,但这毕竟只是一个原型,直播的难点分落在服务器与客户端,比如服务器如何高性能低延迟,客户端如何实时(与协议选择、服务器分布也有关)并处理好声画质量的问题,等等。

以上介绍了通过nginx实现直播的流程,其中一个环节是通过ffmpeg的命令来推流的,那如果想写代码来实现,可以怎么做呢?

这里涉及到FFmpeg的调用,而它的使用应该有更多的前提,比如FFmpeg的编译、引用、调用等等,如果你想在了解这些前置环节之后再作深入了解也是可以的,那就不必阅读下面的内容。但是,为了保持内容的完整性,小程还是加上这部分内容。

(三)用代码实现推流

使用ffmpeg命令来推流,控制度不够高,现在以代码的方式来实现,可灵活控制。

最终的效果是这样的(一边推流到服务器,一边从服务器拉流播放):
推流与播放的效果

演示推流的代码

#include <stdio.h>
#include "ffmpeg/include/libavformat/avformat.h"
#include "ffmpeg/include/libavcodec/avcodec.h"

void publishstream() {
	const char* srcfile = "t.mp4";
	const char* streamseverurl = "rtmp://localhost/rtmpdemo/test1";
	av_register_all();
	avformat_network_init();
	av_log_set_level(AV_LOG_DEBUG);
	int status = 0;
	AVFormatContext* formatcontext = avformat_alloc_context();
	status = avformat_open_input(&formatcontext, srcfile, NULL, NULL);
	if (status >= 0) {
		status = avformat_find_stream_info(formatcontext, NULL);
		if (status >= 0) {
			int videoindex = -1;
			for (int i = 0; i < formatcontext->nb_streams; i ++) {
				if (formatcontext->streams[i]->codec->codec_type == AVMEDIA_TYPE_VIDEO) {
					videoindex = i;
					break;
				}
			}
			if (videoindex >= 0) {
				AVFormatContext* outformatcontext;
				avformat_alloc_output_context2(&outformatcontext, NULL, "flv", streamseverurl);
				if (outformatcontext) {
					status = -1;
					for (int i = 0; i < formatcontext->nb_streams; i ++) {
						AVStream* onestream = formatcontext->streams[i];
						AVStream* newstream = avformat_new_stream(outformatcontext, onestream->codec->codec);
						status = newstream ? 0 : -1;
						if (status == 0) {
							status = avcodec_copy_context(newstream->codec, onestream->codec);
							if (status >= 0) {
								newstream->codec->codec_tag = 0;
								if (outformatcontext->oformat->flags & AVFMT_GLOBALHEADER) {
									newstream->codec->flags |= CODEC_FLAG_GLOBAL_HEADER;
								}
							}
						}
					}
					if (status >= 0) {
						AVOutputFormat* outformat = outformatcontext->oformat;
						av_usleep(5*1000*1000); // 故意等一下再开始推流,让拉流的客户端有时间启动,以拿到视频的pps/sps
						if (!(outformat->flags & AVFMT_NOFILE)) {
							av_dump_format(outformatcontext, 0, streamseverurl, 1);
							status = avio_open(&outformatcontext->pb, streamseverurl, AVIO_FLAG_WRITE);
							if (status >= 0) {
								status = avformat_write_header(outformatcontext, NULL);
								if (status >= 0) {
									AVPacket packet;
									int videoframeidx = 0;
									int64_t starttime = av_gettime();
									while (1) {
										status = av_read_frame(formatcontext, &packet);
										if (status < 0) {
											break;
										}
										if (packet.pts == AV_NOPTS_VALUE) {
											av_log(NULL, AV_LOG_DEBUG, "set pakcet.pts\n");
											AVRational video_time_base = formatcontext->streams[videoindex]->time_base;
											int64_t frameduration = (double)AV_TIME_BASE / av_q2d(formatcontext->streams[videoindex]->r_frame_rate);
											packet.pts = (double)(videoframeidx * frameduration) / (double)(av_q2d(video_time_base) * AV_TIME_BASE);
											packet.dts = packet.pts;
											packet.duration = (double)frameduration / (double)(av_q2d(video_time_base) * AV_TIME_BASE);
										}
										if (packet.stream_index == videoindex) {
											AVRational video_time_base = formatcontext->streams[videoindex]->time_base;
											AVRational time_base_q = {1, AV_TIME_BASE};
											int64_t cur_pts = av_rescale_q(packet.dts, video_time_base, time_base_q);
											int64_t curtime = av_gettime() - starttime;
											av_log(NULL, AV_LOG_DEBUG, "on video frame curpts=%lld curtime=%lld\n", cur_pts, curtime);
											if (cur_pts > curtime) {
												av_usleep(cur_pts - curtime);
											}
										}
										AVStream* instream = formatcontext->streams[packet.stream_index];
										AVStream* outstream = outformatcontext->streams[packet.stream_index];
										packet.pts = av_rescale_q_rnd(packet.pts, instream->time_base, outstream->time_base, AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX);
										packet.dts = av_rescale_q_rnd(packet.dts, instream->time_base, outstream->time_base, AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX);
										packet.duration = av_rescale_q(packet.duration, instream->time_base, outstream->time_base);
										packet.pos = -1;
										if (packet.stream_index == videoindex) {
											videoframeidx ++;
										}
										status = av_interleaved_write_frame(outformatcontext, &packet);
										if (status < 0) {
											break;
										}
									}
									av_write_trailer(outformatcontext);
								}
								avio_close(outformatcontext->pb);
							}
						}
					}
					avformat_free_context(outformatcontext);
				}
			}
		}
		avformat_close_input(&formatcontext);
	}
	avformat_free_context(formatcontext);
}

int main(int argc, char *argv[])
{
	publishstream();
	return 0;
}

这里以本地的视频文件作为内容,模拟了直播推流(推到nginx),功能上相当于直接调用ffmpeg命令:

sudo ffmpeg -re -i Movie-1.mp4 -vcodec copy -f flv rtmp://localhost/rtmpdemo/test1

当然也可以边录制边推送,也可以在不同的电脑或手机上,拉流播放。

直播开始后,这里的流媒体服务器并没有给中途拉流的客户端发送视频解码所必须的参数(pps/sps),所以在测试的时候,要保证拉流端能拿到第一帧数据,比如演示代码中故意sleep几秒后才开始推流,让拉流端有时间开启并拿到推上去的所有数据(包括关键参数)。

好了,这个直播的原型,通过nginx来做其实很简单,更多的,应该是对原理的理解。到此为止,有缘再见吧,see you。

Tags: