多媒體開發(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: