1. 简介
流媒体是使用了流式传输的多媒体应用技术。如下是维基百科关于流媒体概念的定义:
流媒体 (streaming media) 是指将一连串的媒体数据压缩后,经过网络分段发送数据,在网络上即时传输影音以供观赏的一种技术与过程,此技术使得数据包得以像流水一样发送;如果不使用此技术,就必须在使用前下载整个媒体文件。
关于流媒体的基础概念,观止云的 “流媒体|从入门到出家” 系列文章极具参考价值,请参考本文第 5 节参考资料部分。
1.1 FFmpeg 影音处理的层次
FFmpeg 中对影音数据的处理,可以划分为协议层、容器层、编码层与原始数据层四个层次:
协议层:提供网络协议收发功能,可以接收或推送含封装格式的媒体流。协议层由 libavformat 库及第三方库(如 librtmp)提供支持。
容器层:处理各种封装格式。容器层由 libavformat 库提供支持。
编码层:处理音视频编码及解码。编码层由各种丰富的编解码器(libavcodec 库及第三方编解码库(如 libx264))提供支持。
原始数据层:处理未编码的原始音视频帧。原始数据层由各种丰富的音视频滤镜(libavfilter 库)提供支持。
本文提及的收流与推流的功能,属于协议层的处理。
FFmpeg 中 libavformat 库提供了丰富的协议处理及封装格式处理功能,在打开输入/输出时,FFmpeg 会根据 输入 URL / 输出 URL 探测输入/输出格式,选择合适的协议和封装格式。
例如,如果输出 URL 是 "rtmp://192.168.0.104/live",那么 FFmpeg 打开输出时,会确定使用 rtmp 协议,封装格式为 flv。
FFmpeg 中打开输入/输出的内部处理细节用户不必关注,因此本文流处理的例程和前面转封装的例程非常相似,不同之处主要在于输入/输出 URL 形式不同,若 URL 携带 "rtmp://"、"rpt://"、"udp://"等前缀,则表示涉及流处理;否则,处理的是本地文件。
1.2 流媒体系统中的角色
流媒体系统是一个比较复杂的系统,简单来说涉及三个角色:流媒体服务器、推流客户端和收流客户端。推流客户端是内容生产者,收流客户端是内容消费者。
示意图如下:
1.3 收流与推流
如果输入是网络流,输出是本地文件,则实现的是收流功能,将网络流存储为本地文件,如下:
如果输入是本地文件,输出是网络流,则实现的是推流功能,将本地文件推送到网络,如下:
如果输入是网络流,输出也是网络流,则实现的是转流功能,将一个流媒体服务器上的流推送到另一个流媒体服务器,如下:
2. 源码
源码和转封装例程大部分相同,可以认为是转封装例程的增强版:
代码语言:javascript复制1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
#include <stdbool.h>
#include <libavutil/timestamp.h>
#include <libavformat/avformat.h>
// ffmpeg -re -i tnhaoxc.flv -c copy -f flv rtmp://192.168.0.104/live
// ffmpeg -i rtmp://192.168.0.104/live -c copy tnlinyrx.flv
// ./streamer tnhaoxc.flv rtmp://192.168.0.104/live
// ./streamer rtmp://192.168.0.104/live tnhaoxc.flv
int main(int argc, char **argv)
{
AVOutputFormat *ofmt = NULL;
AVFormatContext *ifmt_ctx = NULL, *ofmt_ctx = NULL;
AVPacket pkt;
const char *in_filename, *out_filename;
int ret, i;
int stream_index = 0;
int *stream_mapping = NULL;
int stream_mapping_size = 0;
if (argc < 3) {
printf("usage: %s input outputn"
"API example program to remux a media file with libavformat and libavcodec.n"
"The output format is guessed according to the file extension.n"
"n", argv[0]);
return 1;
}
in_filename = argv[1];
out_filename = argv[2];
// 1. 打开输入
// 1.1 读取文件头,获取封装格式相关信息
if ((ret = avformat_open_input(&ifmt_ctx, in_filename, 0, 0)) < 0) {
printf("Could not open input file '%s'", in_filename);
goto end;
}
// 1.2 解码一段数据,获取流相关信息
if ((ret = avformat_find_stream_info(ifmt_ctx, 0)) < 0) {
printf("Failed to retrieve input stream information");
goto end;
}
av_dump_format(ifmt_ctx, 0, in_filename, 0);
// 2. 打开输出
// 2.1 分配输出ctx
bool push_stream = false;
char *ofmt_name = NULL;
if (strstr(out_filename, "rtmp://") != NULL) {
push_stream = true;
ofmt_name = "flv";
}
else if (strstr(out_filename, "udp://") != NULL) {
push_stream = true;
ofmt_name = "mpegts";
}
else {
push_stream = false;
ofmt_name = NULL;
}
avformat_alloc_output_context2(&ofmt_ctx, NULL, ofmt_name, out_filename);
if (!ofmt_ctx) {
printf("Could not create output contextn");
ret = AVERROR_UNKNOWN;
goto end;
}
stream_mapping_size = ifmt_ctx->nb_streams;
stream_mapping = av_mallocz_array(stream_mapping_size, sizeof(*stream_mapping));
if (!stream_mapping) {
ret = AVERROR(ENOMEM);
goto end;
}
ofmt = ofmt_ctx->oformat;
AVRational frame_rate;
double duration;
for (i = 0; i < ifmt_ctx->nb_streams; i ) {
AVStream *out_stream;
AVStream *in_stream = ifmt_ctx->streams[i];
AVCodecParameters *in_codecpar = in_stream->codecpar;
if (in_codecpar->codec_type != AVMEDIA_TYPE_AUDIO &&
in_codecpar->codec_type != AVMEDIA_TYPE_VIDEO &&
in_codecpar->codec_type != AVMEDIA_TYPE_SUBTITLE) {
stream_mapping[i] = -1;
continue;
}
if (push_stream && (in_codecpar->codec_type == AVMEDIA_TYPE_VIDEO)) {
frame_rate = av_guess_frame_rate(ifmt_ctx, in_stream, NULL);
duration = (frame_rate.num && frame_rate.den ? av_q2d((AVRational){frame_rate.den, frame_rate.num}) : 0);
}
stream_mapping[i] = stream_index ;
// 2.2 将一个新流(out_stream)添加到输出文件(ofmt_ctx)
out_stream = avformat_new_stream(ofmt_ctx, NULL);
if (!out_stream) {
printf("Failed allocating output streamn");
ret = AVERROR_UNKNOWN;
goto end;
}
// 2.3 将当前输入流中的参数拷贝到输出流中
ret = avcodec_parameters_copy(out_stream->codecpar, in_codecpar);
if (ret < 0) {
printf("Failed to copy codec parametersn");
goto end;
}
out_stream->codecpar->codec_tag = 0;
}
av_dump_format(ofmt_ctx, 0, out_filename, 1);
if (!(ofmt->flags & AVFMT_NOFILE)) { // TODO: 研究AVFMT_NOFILE标志
// 2.4 创建并初始化一个AVIOContext,用以访问URL(out_filename)指定的资源
ret = avio_open(&ofmt_ctx->pb, out_filename, AVIO_FLAG_WRITE);
if (ret < 0) {
printf("Could not open output file '%s'", out_filename);
goto end;
}
}
// 3. 数据处理
// 3.1 写输出文件头
ret = avformat_write_header(ofmt_ctx, NULL);
if (ret < 0) {
printf("Error occurred when opening output filen");
goto end;
}
while (1) {
AVStream *in_stream, *out_stream;
// 3.2 从输出流读取一个packet
ret = av_read_frame(ifmt_ctx, &pkt);
if (ret < 0) {
break;
}
in_stream = ifmt_ctx->streams[pkt.stream_index];
if (pkt.stream_index >= stream_mapping_size ||
stream_mapping[pkt.stream_index] < 0) {
av_packet_unref(&pkt);
continue;
}
int codec_type = in_stream->codecpar->codec_type;
if (push_stream && (codec_type == AVMEDIA_TYPE_VIDEO)) {
av_usleep((int64_t)(duration*AV_TIME_BASE));
}
pkt.stream_index = stream_mapping[pkt.stream_index];
out_stream = ofmt_ctx->streams[pkt.stream_index];
/* copy packet */
// 3.3 更新packet中的pts和dts
// 关于AVStream.time_base(容器中的time_base)的说明:
// 输入:输入流中含有time_base,在avformat_find_stream_info()中可取到每个流中的time_base
// 输出:avformat_write_header()会根据输出的封装格式确定每个流的time_base并写入文件中
// AVPacket.pts和AVPacket.dts的单位是AVStream.time_base,不同的封装格式AVStream.time_base不同
// 所以输出文件中,每个packet需要根据输出封装格式重新计算pts和dts
av_packet_rescale_ts(&pkt, in_stream->time_base, out_stream->time_base);
pkt.pos = -1;
// 3.4 将packet写入输出
ret = av_interleaved_write_frame(ofmt_ctx, &pkt);
if (ret < 0) {
printf("Error muxing packetn");
break;
}
av_packet_unref(&pkt);
}
// 3.5 写输出文件尾
av_write_trailer(ofmt_ctx);
end:
avformat_close_input(&ifmt_ctx);
/* close output */
if (ofmt_ctx && !(ofmt->flags & AVFMT_NOFILE)) {
avio_closep(&ofmt_ctx->pb);
}
avformat_free_context(ofmt_ctx);
av_freep(&stream_mapping);
if (ret < 0 && ret != AVERROR_EOF) {
printf("Error occurred: %sn", av_err2str(ret));
return 1;
}
return 0;
}
2.1 收流
收流的代码与打开普通文件的代码没有区别,打开输入时,FFmpeg 能识别流协议及封装格式,根据相应的协议层代码来接收流,收到流数据去掉协议层后得到的数据和普通文件内容是一样的,后续的处理流程也就一样了。
2.2 推流
推流有两个需要注意的地方。
一是需要根据输出流协议显式指定输出 URL 的封装格式:
代码语言:javascript复制1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
bool push_stream = false;
char *ofmt_name = NULL;
if (strstr(out_filename, "rtmp://") != NULL) {
push_stream = true;
ofmt_name = "flv";
}
else if (strstr(out_filename, "udp://") != NULL) {
push_stream = true;
ofmt_name = "mpegts";
}
else {
push_stream = false;
ofmt_name = NULL;
}
avformat_alloc_output_context2(&ofmt_ctx, NULL, ofmt_name, out_filename);
这里只写了两种。rtmp 推流必须推送 flv 封装格式,udp 推流必须推送 mpegts 封装格式,其他情况就当作是输出普通文件。
这里使用 push_stream 变量来标志是否使用推流功能,这个标志后面会用到。
二是要注意推流的速度,不能一股脑将收到的数据全推出去,这样流媒体服务器承受不住。可以按视频播放速度(帧率)来推流。因此每推送一个视频帧,要延时一个视频帧的时长。音频流的数据量很小,可以不必关心此问题。
在打开输入 URL 时,获取视频帧的持续时长:
代码语言:javascript复制1
2
3
4
5
6
AVRational frame_rate;
double duration;
if (push_stream && (in_codecpar->codec_type == AVMEDIA_TYPE_VIDEO)) {
frame_rate = av_guess_frame_rate(ifmt_ctx, in_stream, NULL);
duration = (frame_rate.num && frame_rate.den ? av_q2d((AVRational){frame_rate.den, frame_rate.num}) : 0);
}
在 av_read_frame() 之后,av_interleaved_write_frame() 之前增加延时,延时时长就是一个视频帧的持续时长:
代码语言:javascript复制1
2
3
4
int codec_type = in_stream->codecpar->codec_type;
if (push_stream && (codec_type == AVMEDIA_TYPE_VIDEO)) {
av_usleep((int64_t)(duration*AV_TIME_BASE));
}
3. 验证
3.1 编译第三方库 librtmp
FFmpeg 默认并不支持 rtmp 协议。需要先编译安装第三方库 librtmp,然后开启 --enable-librtmp 选项重新编译安装 FFmpeg。具体方法参考:"FFmpeg 开发环境构建"
3.2 搭建流媒体服务器
测试收流与推流功能需要搭建流媒体服务器。我们选用 nginx-rtmp 作为流媒体服务器用于测试。nginx-rtmp 服务器运行于虚拟机上,推流客户端与收流客户端和 nginx-rtmp 服务器处于同一局域网即可。
我的虚拟机是 OPENSUSE LEAP 42.3,IP 是 192.168.0.104(就是 nginx-rtmp 服务器的地址)。
为避免搭建服务器的繁琐过程,我们直接使用 docker 拉取一个 nginx-rtmp 镜像。步骤如下:
[1] 安装与配置docker服务
安装 docker:
代码语言:javascript复制sudo zypper install docker
将当前用户添加到 docker 组(若 docker 组不存在则先创建),从而可以免 sudo 使用 docker 命令:
代码语言:javascript复制sudo gpasswd -a ${USER} docker
[2] 配置镜像加速
docker 镜像源位于美国,摘取镜像非常缓慢。可配置国内镜像源,加快镜像拉取速度。
修改 /etc/docker/daemon.json 文件并添加上 registry-mirrors 键值:
代码语言:javascript复制1
2
3
4
5
6
7
8
9
{
"registry-mirrors":
[
"https://registry.docker-cn.com",
"https://docker.mirrors.ustc.edu.cn",
"https://hub-mirror.c.163.com",
"https://mirror.ccs.tencentyun.com"
]
}
上述配置文件添加了四个国内镜像源:docker 中国、清华、163 和腾讯。
修改配置文件后重启 docker 服务:
代码语言:javascript复制systemctl restart docker
[3] 拉取 nginx-rtmp 镜像
代码语言:javascript复制docker pull tiangolo/nginx-rtmp
[4] 打开容器
代码语言:javascript复制docker run -d -p 1935:1935 --name nginx-rtmp tiangolo/nginx-rtmp
[5] 防火墙添加例外端口
如果无法推流,应在防火墙中将 1935 端口添加例外
openSUSE 系统:修改 /etc/sysconfig/SuSEfirewall2 文件,在 FW_SERVICES_EXT_TCP 项中添加 1935 端口,如下:
代码语言:javascript复制FW_SERVICES_EXT_TCP="ssh 1935"
然后重启防火墙:
代码语言:javascript复制systemctl restart SuSEfirewall2
CentOS 8 系统:运行如下命令将 1935 端口添加到防火墙例外端口中:
代码语言:javascript复制firewall-cmd --permanent --zone=public --add-port=1935/tcp
[6] 验证服务器
测试文件下载(右键另存为):tnhaoxc.flv
ffmpeg 推流测试:
代码语言:javascript复制ffmpeg -re -i tnhaoxc.flv -c copy -f flv rtmp://192.168.0.104/live
"-re":按视频帧率的速度读取输入 "-c copy":输出流使用和输入流相同的编解码器 "-f flv":指定输出流封装格式为flv
ffplay 收流播放测试:
代码语言:javascript复制ffplay rtmp://192.168.0.104/live
ffplay 播放正常,说明 nginx-rtmp 流媒体服务器搭建成功,可正常使用。
3.3 编译
在 shell 中运行如下命令下载例程源码:
代码语言:javascript复制svn checkout https://github.com/leichn/exercises/trunk/source/ffmpeg/ffmpeg_stream
在源码目录执行 ./compile.sh 命令,生成 streamer 可执行文件。
3.4 验证
测试文件下载(右键另存为):shifu.mkv,将测试文件保存在和源码同一目录。
推流测试:
代码语言:javascript复制./streamer shifu.mkv rtmp://192.168.0.104/live
使用 vlc 播放器打开网络串流,输入流地址 "rtmp://192.168.0.104/live",播放正常。上述测试命令等价于:
代码语言:javascript复制ffmpeg -re -i shifu.mkv -c copy -f flv rtmp://192.168.0.104/live
收流测试:先按照上一步命令启动推流,然后运行如下命令收流
代码语言:javascript复制./streamer rtmp://192.168.0.104/live shifu.ts
以上测试命令等价于:
代码语言:javascript复制ffmpeg -i rtmp://192.168.0.104/live -c copy shifu.ts
接收结束后检查一下生成的本地文件 shifu.ts 能否正常播放。
4. 遗留问题
推流的问题:不管是用 ffmpeg 命令,还是用本测试程序,推流结束时会打印如下信息
代码语言:javascript复制1
2
3
4
[flv @ 0x22ab9c0] Timestamps are unset in a packet for stream 0. This is deprecated and will stop working in the future. Fix your code to set the timestamps properly
Larger timestamp than 24-bit: 0xffffffc2
[flv @ 0x22ab9c0] Failed to update header with correct duration.
[flv @ 0x22ab9c0] Failed to update header with correct filesize.
收流的问题:推流结束后,收流超时未收以数据,会打印如下信息后程序退出运行
代码语言:javascript复制RTMP_ReadPacket, failed to read RTMP packet header
5. 参考资料
[1] 雷霄骅, RTMP流媒体技术零基础学习方法 [2] 观止云, 【流媒体|从入门到出家】:流媒体原理(上) [3] 观止云, 【流媒体|从入门到出家】:流媒体原理(下) [4] 观止云, 【流媒体|从入门到出家】:流媒体系统(上) [5] 观止云, 【流媒体|从入门到出家】:流媒体系统(下) [6] 观止云, 总结:从一个直播APP看流媒体系统的应用
-- END --