流媒体实战之RTMP推送FLV

流媒体实战之推送MP4文件

前言

在我们日常工作中会遇到流媒体相关的业务,特别是音视频领域的小伙伴们,那么什么是流媒体呢?

流媒体协议说白了:就是一种通过Web来传递多媒体的一种协议,当我们在网络上观看视频或者直播时,背后避免不了会与流媒体协议有牵扯。

而流媒体协议有很多种,其主要也分为三大类:

  • 传统视频流媒体协议:
    • RTMP
    • RTSP
  • 基于HTTP的流媒体协议:
    • Apple HLS
    • Low-Latency HLS
    • MPEG-DASH
    • Adobe HDS
  • 新流媒体协议:
    • SRT
    • WebRTC

本篇文章主要基于 RTMP 进行讲解。

工作原理

RTMP 主要的工作流程如下:

未命名文件

如上图所示:首先是由用户A来将一个媒体流推送至流媒体服务器,流媒体服务器接收到流之后再做一系列的处理。待处理完成之后,此时用户B如果来拉取媒体流,流媒体服务器则会将该流分发给用户B。

前置准备

在我们编写我们的代码之前,我们要先去做一些准备工作:

  1. 服务器/虚拟机
  2. SRS
  3. ffmpeg

在我们编写代码之前,我们首先要将流媒体服务器准备好,这里可以用我们自己的服务器,也可以用虚拟机或者本机。

手动安装

安装ffmpeg

首先我们可以去 http://www.ffmpeg.org/releases 中找到我们所需的版本下载下来,如下:

1
2
3
4
5
6
7
8
9
10
11
# 下载ffmpeg
wget http://www.ffmpeg.org/releases/ffmpeg-6.1.tar.gz

# 解压
tar -zxvf ffmpeg-6.1.tar.gz /opt/ffmpeg-6.1

cd /opt/ffmpeg-6.1

# 编译安装
./configure --prefix=/usr/local/ffmpeg
make && make install

当我们安装完成后还要去配置环境变量,如下:

1
2
3
4
5
6
7
vim /etc/profile

# 在文件最后一行添加环境变量
export PATH=$PATH:/usr/local/ffmpeg/bin

# :wq退出后刷新资源以生效配置
source /etc/profile

此时我们就已经安装完成了,我们也可以通过 ffmpeg -version 命令来验证安装。

安装SRS

首先我们要来安装我们的SRS,首先我们要先在服务器上将srs源码给克隆下来

1
git clone -b 5.0release https://gitee.com/ossrs/srs.git /opt/srs

当我们将源码克隆下来之后就可以进行编译安装了

1
2
3
4
5
# 切换目录
cd /opt/srs/trunk

# 编译安装
./configure --prefix=/usr/local/srs && make

在安装完成之后,我们也可以去 /usr/local/srs/conf/srs/conf 下来更改相应配置

Docker安装

在命令行中执行如下代码:

1
docker run -p 1935:1935 -p 1985:1985 -p 8080:8080 ossrs/srs:3

代码实现

在编写代码之前,我们要先知晓推流的整体流程来辅助我们编码,如下图所示:

未命名文件

我们知道了其工作原理后,就可以来编写我们的代码了。我们的目的是将一个MP4文件推送至流媒体服务器,再通过 VLC 来拉取我们的流。代码如下:

Controller

我们通过 GET 请求方式,且参数为 sourceFilePathpushAddress ,他们分别是推送MP4文件的本地路径和推送的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import com.bummon.service.PushService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* @author Bummon
* @description
* @date 2023-12-27 16:38
*/
@RestController
@RequiredArgsConstructor
public class PushController {

private final PushService pushService;

@GetMapping("/pushFlv")
public void pushFlv(String sourceFilePath, String pushAddress) throws Exception {
pushService.pushFlv(sourceFilePath, pushAddress);
}

}

Service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* @author Bummon
* @description
* @date 2023-12-27 17:04
*/
public interface PushService {
/**
* @param sourceFilePath mp4本地路径
* @param pushAddress 推送地址
* @return
* @date 2023-12-27 17:06
* @author Bummon
* @description 推送flv
*/
void pushFlv(String sourceFilePath, String pushAddress) throws Exception;

}

ServiceImpl

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
import com.bummon.service.PushService;
import lombok.extern.slf4j.Slf4j;
import org.bytedeco.ffmpeg.avcodec.AVCodecParameters;
import org.bytedeco.ffmpeg.avformat.AVFormatContext;
import org.bytedeco.ffmpeg.avformat.AVStream;
import org.bytedeco.ffmpeg.global.avcodec;
import org.bytedeco.ffmpeg.global.avutil;
import org.bytedeco.javacv.FFmpegFrameGrabber;
import org.bytedeco.javacv.FFmpegFrameRecorder;
import org.bytedeco.javacv.FFmpegLogCallback;
import org.bytedeco.javacv.Frame;
import org.springframework.stereotype.Service;

/**
* @author Bummon
* @description
* @date 2023-12-27 17:07
*/
@Slf4j
@Service
public class PushServiceImpl implements PushService {
@Override
public void pushFlv(String sourceFilePath, String pushAddress) throws Exception {
// 实例化帧抓取器对象,将文件路径传入
FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(sourceFilePath);
long startTime = System.currentTimeMillis();

// 初始化帧抓取器,例如数据结构(时间戳、编码器上下文、帧对象等),
// 如果入参等于true,还会调用avformat_find_stream_info方法获取流的信息,放入AVFormatContext类型的成员变量oc中
grabber.start(true);

log.info("帧抓取器初始化完毕,耗时[{}]毫秒", System.currentTimeMillis() - startTime);

// grabber.start方法中,初始化的解码器信息存在放在grabber的成员变量oc中
AVFormatContext avFormatContext = grabber.getFormatContext();

//文件中有几个媒体流(视频流+音频流)
int streamNum = avFormatContext.nb_streams();

if (streamNum < 1) {
log.error("文件中不存在媒体流");
return;
}

// 获取视频帧率
int videoFrameRate = (int) grabber.getVideoFrameRate();
log.info("检测到视频帧率:[{}],视频时长:[{}]秒,媒体流数量:[{}]个", videoFrameRate, avFormatContext.duration() / 1000000, streamNum);

// 遍历媒体流并检查其类型
for (int i = 0; i < streamNum; i++) {
AVStream avStream = avFormatContext.streams(i);
AVCodecParameters avCodecParameters = avStream.codecpar();
log.info("检测到流索引:[{}],编码器类型:[{}],编码器ID:[{}]", i, avCodecParameters.codec_type(), avCodecParameters.codec_id());
}

// 视频宽度
int width = grabber.getImageWidth();
// 视频高度
int height = grabber.getImageHeight();
// 音频通道数量
int audioChannels = grabber.getAudioChannels();

log.info("检测到视频宽度:[{}],视频高度:[{}],音频通道数:[{}]", width, height, audioChannels);

FFmpegFrameRecorder recorder = new FFmpegFrameRecorder(pushAddress, width, height, audioChannels);

// 设置编码格式
recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264);

// 设置封装格式
recorder.setFormat("flv");

// 设置两个关键帧中间的帧数
recorder.setFrameRate(videoFrameRate);

// 设置音频通道数
recorder.setAudioChannels(2);

startTime = System.currentTimeMillis();

// 初始化帧录制器
recorder.start();

log.info("帧录制器初始化完毕,耗时:[{}]毫秒", System.currentTimeMillis() - startTime);

Frame frame;

startTime = System.currentTimeMillis();

log.info("开始推流");

long videoTS = 0;

int videoFrameNum = 0;
int audioFrameNum = 0;
int dataFrameNum = 0;

// 假设一秒钟15帧,那么两帧间隔就是(1000/15)毫秒
int interVal = 1000 / videoFrameRate;

// 发送完一帧后sleep的时间,不能完全等于(1000/frameRate),不然会卡顿,
// 要更小一些,这里取八分之一
interVal /= 8;

// 持续从视频源取帧
while (null != (frame = grabber.grab())) {
videoTS = 1000 * (System.currentTimeMillis() - startTime);

// 时间戳
recorder.setTimestamp(videoTS);

// 有图像则视频帧+1
if (null != frame.image) {
videoFrameNum++;
}

// 有声音则音频帧+1
if (null != frame.samples) {
audioFrameNum++;
}

// 有数据则数据帧+1
if (null != frame.data) {
dataFrameNum++;
}

// 将每帧取出并推送至SRS
recorder.record(frame);

// 停顿一下再推送
Thread.sleep(interVal);
}
log.info("推送完成,视频帧:[{}],音频帧:[{}],数据帧:[{}],耗时:[{}]毫秒", videoFrameNum, audioFrameNum, dataFrameNum, System.currentTimeMillis() - startTime);

// 关闭帧录制器
recorder.close();

// 关闭帧抓取器
grabber.close();
}
}

测试

我们使用postman请求如下:

image-20231227181257363

然后我们再通过 VLC 来拉取我们的流

image-20231227181216886

可以发现我们是可以正确拉取到对应的流的,也说明我们的服务没有问题。

本文代码

https://github.com/Bummon/media-in-action/tree/main/push-mp4

至此,本篇文章结束,感谢观看。