在之前完成的实战项目【FFmpeg音视频播放器】属于拉流范畴,接下来将完成推流工作,通过RTMP实现推流,即直播客户端。简单的说,就是将手机采集的音频数据和视频数据,推到服务器端。
接下来的RTMP直播客户端系列,主要实现红框和紫色部分:
本节主要内容:
1.Java层视频编码工作。
2.Native层视频编码器工作。
3.Native层视频推流编码工作。
源码:
NdkPush: 通过RTMP实现推流,直播客户端。
一、Java层视频编码
1)MainActivity:
MainActivity只与中转站NdkPusher打交道,用户操作页面相关功能是调用NdkPusher分发下去;
初始化NdkPusher.java
mNdkPusher = new NdkPusher(this, Camera.CameraInfo.CAMERA_FACING_BACK, 640, 480, 25, 800000);
首次点击【切换摄像头】时,设置Camera与Surface绑定
/**
* 切换摄像头
*
* @param view
*/
public void switchCamera(View view) {
if (initPermission()) {
if (!isBind) {
mNdkPusher.setPreviewDisplay(mSurfaceHolder);
isBind = true;
}
mNdkPusher.switchCamera();
}
}
点击【开始直播】时,开始直播,并设置rtmp服务器地址
/**
* 开始直播
*
* @param view
*/
public void startLive(View view) {
mNdkPusher.startLive("rtmp://139.224.136.101/myapp");
}
点击【停止直播】时,停止直播
/**
* 停止直播
*
* @param view
*/
public void stopLive(View view) {
mNdkPusher.stopLive();
}
页面关闭,释放资源
/**
* 释放工作
*/
@Override
protected void onDestroy() {
super.onDestroy();
mNdkPusher.release();
}
2)NdkPusher:
中转站,分发MainActivity事件和和Native层打交道;
NdkPusher初始化时,主要是的三件事,
①:初始化native层需要的加载,
②:实例化视频通道并传递基本参数(宽高,fps,码率等),
③:实例化音频通道(下一节内容)
public NdkPusher(Activity activity, int cameraId, int width, int height, int fps, int bitrate) {
native_init();
// 将this传递给VideoChannel,方便VideoChannel操控native层
mVideoChannel = new VideoChannel(this, activity, cameraId, width, height, fps, bitrate);
}
分发给视频通道VideoChannel-->SurfaceView与中转站里面的Camera绑定
public void setPreviewDisplay(SurfaceHolder surfaceHolder) {
mVideoChannel.setPreviewDisplay(surfaceHolder);
}
分发给视频通道VideoChannel-->切换摄像头
public void switchCamera() {
mVideoChannel.switchCamera();
}
开始直播,调用native层开始直播工作,分发给视频通道VideoChannel开始直播
public void startLive(String path) {
native_start(path);
mVideoChannel.startLive();
}
停止直播,调用native层停止直播工作,分发给视频通道VideoChannel停止直播
public void stopLive() {
mVideoChannel.stopLive();
native_stop();
}
释放工作,释放native层数据和视频通道VideoChannel
public void release() {
mVideoChannel.release();
native_release();
}
与native层通讯函数
// 音频 视频 公用的
private native void native_init(); // 初始化
private native void native_start(String path); // 开始直播start(音频视频通用一套代码) path:rtmp推流地址
private native void native_stop(); // 停止直播
private native void native_release(); // onDestroy--->release释放工作
// 下面是视频独有
public native void native_initVideoEncoder(int width, int height, int mFps, int bitrate); // 初始化x264编码器
public native void native_pushVideo(byte[] data); // 相机画面的数据 byte[] 推给 native层
3)VideoChannel:
视频通道,处理NdkPusher分发下来的事件和将CameraHelper的Camera画面数据推送到native层。
初始化CameraHelper,设置Camera相机预览帮助类,onPreviewFrame(nv21)数据的回调监听和宽高发送改变的监听
public VideoChannel(NdkPusher ndkPusher, Activity activity, int cameraId, int width, int height, int fps, int bitrate) {
this.mNdkPusher = ndkPusher; // 回调给中转站
this.mFps = fps; // fps 每秒钟多少帧
this.bitrate = bitrate; // 码率
mCameraHelper = new CameraHelper(activity, cameraId, width, height);
mCameraHelper.setPreviewCallback(this); // 设置Camera相机预览帮助类,onPreviewFrame(nv21)数据的回调监听
mCameraHelper.setOnChangedSizeListener(this); // 宽高发送改变的监听回调设置
}
调用帮助类:与Surface绑定
public void setPreviewDisplay(SurfaceHolder surfaceHolder) {
mCameraHelper.setPreviewDisplay(surfaceHolder);
}
调用帮助类-->切换摄像头
public void switchCamera() {
mCameraHelper.switchCamera();
}
开始直播,只修改标记 让其可以进入if 完成图像数据推送
public void startLive() {
isLive = true;
}
停止直播,只修改标记 让其可以不要进入if 就不会再数据推送了
public void stopLive() {
isLive = false;
}
释放,调用帮助类-->停止预览
public void release() {
mCameraHelper.stopPreview();
}
Camera预览画面的数据,回调到这里,再通过mNdkPusher,将数据推送到native层
@Override
public void onPreviewFrame(byte[] data, Camera camera) {
// data == nv21 数据
if (isLive) {
// 图像数据推送
mNdkPusher.native_pushVideo(data);
}
}
Camera发送宽高改变,回调到这里,再通过mNdkPusher,将数据推送到native层
@Override
public void onChanged(int width, int height) {
// 视频编码器的初始化有关:width,height,fps,bitrate
mNdkPusher.native_initVideoEncoder(width, height, mFps, bitrate); // 初始化x264编码器
}
4)CameraHelper第一节已完成。
二、Native层视频编码器
1)native-lib.cpp:
处理Java层NdkPusher调用的native函数;
native层初始化工作:
NdkPusher构造函数调用到这里,初始化native层VideoChannel,设置 Camera预览画面的数据推送到native层,videoChannel编码后数据,通过callback回调到native-lib.cpp,加入队列。
extern "C"
JNIEXPORT void JNICALL
Java_com_ndk_push_NdkPusher_native_1init(JNIEnv *env, jobject thiz) {
// 初始化 VideoChannel
videoChannel = new VideoChannel();
// 设置 Camera预览画面的数据推送到native层,videoChannel编码后数据,通过callback回调到native-lib.cpp,加入队列
videoChannel->setVideoCallback(callback);
// 设置 队列的释放工作 回调
packets.setReleaseCallback(releasePackets);
}
videoCallback 函数指针的实现(将编码后数据存放packet到队列)
void callback(RTMPPacket *packet) {
if (packet) {
if (packet->m_nTimeStamp == -1) {
packet->m_nTimeStamp = RTMP_GetTime() - start_time; // 如果是sps+pps 没有时间搓,如果是I帧就需要有时间搓
}
packets.push(packet); // 存入队列里面
}
}
释放RTMPPacket * 包的函数指针实现,T无法释放, 让外界释放
void releasePackets(RTMPPacket **packet) {
if (packet) {
RTMPPacket_Free(*packet);
delete packet;
packet = nullptr;
}
}
开始直播 ---> 启动工作
创建子线程实现:
1.连接流媒体服务器;
2.发包;
extern "C"
JNIEXPORT void JNICALL
Java_com_ndk_push_NdkPusher_native_1start(JNIEnv *env, jobject thiz, jstring path_) {
/**
* 创建子线程:
* 1.连接流媒体服务器;
* 2.发包;
*/
if (isStart) {
return;
}
isStart = true;
const char *path = env->GetStringUTFChars(path_, nullptr);
// 深拷贝
char *url = new char(strlen(path) + 1); // C++的堆区开辟 new -- delete
strcpy(url, path);
// 创建线程来进行直播
pthread_create(&pid_start, nullptr, task_start, url);
env->ReleaseStringUTFChars(path_, path); // 你随意释放,我已经深拷贝了
}
连接RTMP服务器,遍历压缩包队列,将数据发送到RTMP服务器
void *task_start(void *args) {
char *url = static_cast(args);
// RTMPDump API 九部曲
RTMP *rtmp = nullptr;
int result; // 返回值判断成功失败
do {
// 1.1,rtmp 初始化
rtmp = RTMP_Alloc();
if (!rtmp) {
LOGE("rtmp 初始化失败");
break;
}
// 1.2,rtmp 初始化
RTMP_Init(rtmp);
rtmp->Link.timeout = 5; // 设置连接的超时时间(以秒为单位的连接超时)
// 2,rtmp 设置流媒体地址
result = RTMP_SetupURL(rtmp, url);
if (!result) { // result == 0 和 ffmpeg不同,0代表失败
LOGE("rtmp 设置流媒体地址失败");
break;
}
// 3,开启输出模式
RTMP_EnableWrite(rtmp);
// 4,建立连接
result = RTMP_Connect(rtmp, nullptr);
if (!result) { // result == 0 和 ffmpeg不同,0代表失败
LOGE("rtmp 建立连接失败:%d, url: %s", result, url);
break;
}
// 5,连接流
result = RTMP_ConnectStream(rtmp, 0);
if (!result) { // result == 0 和 ffmpeg不同,0代表失败
LOGE("rtmp 连接流失败");
break;
}
start_time = RTMP_GetTime();
// 准备好了,可以开始向服务器推流了
readyPushing = true;
// 队列开始工作
packets.setWork(1);
RTMPPacket *packet = nullptr;
// 从队列里面获取压缩包,直接发给服务器
while (readyPushing) {
packets.pop(packet); // 阻塞式
if (!readyPushing) {
break;
}
// 取不到数据,重新取,可能还没生产出来
if (!packet) {
continue;
}
// 到这里就是成功的获取队列的ptk了,可以发送给流媒体服务器
packet->m_nInfoField2 = rtmp->m_stream_id;// 给rtmp的流id
// 成功取出数据包,发送
result = RTMP_SendPacket(rtmp, packet, 1); // 1==true 开启内部缓冲
// packet 你都发给服务器了,可以大胆释放
releasePackets(&packet);
if (!result) { // result == 0 和 ffmpeg不同,0代表失败
LOGE("rtmp 失败 自动断开服务器");
break;
}
}
releasePackets(&packet); // 只要跳出循环,就释放
} while (false);
// 本次一系列释放工作
isStart = false;
readyPushing = false;
packets.setWork(0);
packets.clear();
if (rtmp) {
RTMP_Close(rtmp);
RTMP_Free(rtmp);
}
delete url;
return nullptr;
}
初始化x264编码器,Camera宽高改变,回调到这里,首次设置预览时触发;分发到VideoChannel视频通道初始化编码器。
extern "C"
JNIEXPORT void JNICALL
Java_com_ndk_push_NdkPusher_native_1initVideoEncoder(JNIEnv *env, jobject thiz, jint width,
jint height, jint fps, jint bitrate) {
if (videoChannel) {
videoChannel->initVideoEncoder(width, height, fps, bitrate);
}
}
Camera预览画面的数据,回调到这里,将原始数据进行x264编码后,得到的RTMPPkt(压缩数据)加入队列里面
extern "C"
JNIEXPORT void JNICALL
Java_com_ndk_push_NdkPusher_native_1pushVideo(JNIEnv *env, jobject thiz, jbyteArray data_) {
if (!videoChannel || !readyPushing) { return; }
// 把jni ---> C语言的
jbyte *data = env->GetByteArrayElements(data_, nullptr);
// data == nv21数据,编码,加入队列
videoChannel->encodeData(data);
env->ReleaseByteArrayElements(data_, data, 0); // 释放byte[]
}
2)VideoChannel.cpp:
native层视频通道,初始化x264编码器和处理相机原始数据编码,再回到给native-lib.cpp,加入队列。
初始化 x264 编码器
void VideoChannel::initVideoEncoder(int width, int height, int fps, int bitrate) {
// 防止编码器多次创建 互斥锁
pthread_mutex_lock(&mutex);
mWidth = width;
mHeight = height;
mFps = fps;
mBitrate = bitrate;
y_len = width * height;
uv_len = y_len / 4;
// 防止重复初始化x264编码器
if (videoEncoder) {
x264_encoder_close(videoEncoder);
videoEncoder = nullptr;
}
// 防止重复初始化pic_in
if (pic_in) {
x264_picture_clean(pic_in);
DELETE(pic_in);
}
// TODO 初始化x264编码器
x264_param_t param;// x264的参数集
// 设置编码器属性
// ultrafast 最快 (直播必须快)
// zerolatency 零延迟(直播必须快)
x264_param_default_preset(¶m, "ultrafast", "zerolatency");
// 编码规格:https://wikipedia.tw.wjbk.site/wiki/H.264 看图片
param.i_level_idc = 32; // 3.2 中等偏上的规格 自动用 码率,模糊程度,分辨率
// 输入数据格式是 YUV420P 平面模式VVVVVUUUU,如果没有P, 就是交错模式VUVUVUVU
param.i_csp = X264_CSP_I420;
param.i_width = width;
param.i_height = height;
// 不能有B帧,如果有B帧会影响编码、解码效率(快)
param.i_bframe = 0;
// 码率控制方式。CQP(恒定质量),CRF(恒定码率),ABR(平均码率)
param.rc.i_rc_method = X264_RC_CRF;
// 设置码率
param.rc.i_bitrate = bitrate / 1000;
// 瞬时最大码率 网络波动导致的
param.rc.i_vbv_max_bitrate = bitrate / 1000 * 1.2;
// 设置了i_vbv_max_bitrate就必须设置buffer大小,码率控制区大小,单位Kb/s
param.rc.i_vbv_buffer_size = bitrate / 1000;
// 码率控制不是通过 timebase 和 timestamp,码率的控制,完全不用时间搓 ,而是通过 fps 来控制 码率(根据你的fps来自动控制)
param.b_vfr_input = 0;
// 分子 分母
// 帧率分子
param.i_fps_num = fps;
// 帧率分母
param.i_fps_den = 1;
param.i_timebase_den = param.i_fps_num;
param.i_timebase_num = param.i_fps_den;
// 告诉人家,到底是什么时候,来一个I帧, 计算关键帧的距离
// 帧距离(关键帧) 2s一个关键帧 (就是把两秒钟一个关键帧告诉人家)
param.i_keyint_max = fps * 2;
// sps序列参数 pps图像参数集,所以需要设置header(sps pps)
// 是否复制sps和pps放在每个关键帧的前面 该参数设置是让每个关键帧(I帧)都附带sps/pps。
param.b_repeat_headers = 1;
// 并行编码线程数
param.i_threads = 1;
// profile级别,baseline级别 (把我们上面的参数进行提交)
x264_param_apply_profile(¶m, "baseline");
// 输入图像初始化
pic_in = new x264_picture_t(); // 本身空间的初始化
x264_picture_alloc(pic_in, param.i_csp, param.i_width, param.i_height); // pic_in内部成员初始化等
// 打开编码器 一旦打开成功,我们的编码器就拿到了
videoEncoder = x264_encoder_open(¶m);
if (videoEncoder) {
LOGE("x264编码器打开成功");
}
pthread_mutex_unlock(&mutex);
}
视频原始数据编码工作
void VideoChannel::encodeData(signed char *data) {
pthread_mutex_lock(&mutex);
// 把nv21的y分量 Copy i420的y分量
memcpy(pic_in->img.plane[0], data, y_len);
// 把nv21的vuvuvuvu 转化成 i420的 uuuuvvvv
for (int i = 0; i < uv_len; ++i) {
// u 数据
// data + y_len + i * 2 + 1 : 移动指针取 data(nv21) 中 u 的数据
*(pic_in->img.plane[1] + i) = *(data + y_len + i * 2 + 1);
// v 数据
// data + y_len + i * 2 : 移动指针取 data(nv21) 中 v 的数据
*(pic_in->img.plane[2] + i) = *(data + y_len + i * 2);
}
x264_nal_t *nal = nullptr; // 通过H.264编码得到NAL数组(理解)
int pi_nal; // pi_nal是nal中输出的NAL单元的数量
x264_picture_t pic_out; // 输出编码后图片 (编码后的图片)
// 1.视频编码器, 2.nal, 3.pi_nal是nal中输出的NAL单元的数量, 4.输入原始的图片, 5.输出编码后图片
int ret = x264_encoder_encode(videoEncoder, &nal, &pi_nal, pic_in,
&pic_out); // 进行编码(本质的理解是:编码一张图片)
if (ret < 0) { // 返回值:x264_encoder_encode函数 返回返回的 NAL 中的字节数。如果没有返回 NAL 单元,则在错误时返回负数和零。
LOGE("x264编码失败");
pthread_mutex_unlock(&mutex); // 注意:一旦编码失败了,一定要解锁,否则有概率性造成死锁了
return;
}
// 发送 Packets 入队queue
// sps(序列参数集) pps(图像参数集) 说白了就是:告诉我们如何解码图像数据
int sps_len, pps_len; // sps 和 pps 的长度
uint8_t sps[100]; // 用于接收 sps 的数组定义
uint8_t pps[100]; // 用于接收 pps 的数组定义
pic_in->i_pts += 1; // pts显示的时间(+=1 目的是每次都累加下去), dts编码的时间
// 遍历nal中输出的NAL单元,组件压缩包数据,加入队列
for (int i = 0; i < pi_nal; ++i) {
if (nal[i].i_type == NAL_SPS) {
sps_len = nal[i].i_payload - 4; // 去掉起始码(之前我们学过的内容:00 00 00 01)
memcpy(sps, nal[i].p_payload + 4, sps_len); // 由于上面减了4,所以+4挪动这里的位置开始
} else if (nal[i].i_type == NAL_PPS) {
pps_len = nal[i].i_payload - 4; // 去掉起始码 之前我们学过的内容:00 00 00 01)
memcpy(pps, nal[i].p_payload + 4, pps_len); // 由于上面减了4,所以+4挪动这里的位置开始
// sps + pps == 1个压缩包数据
sendSpsPps(sps, pps, sps_len, pps_len); // pps是跟在sps后面的,这里拿到的pps表示前面的sps肯定拿到了
} else {
// 发送 I帧 P帧
sendFrame(nal[i].i_type, nal[i].i_payload, nal[i].p_payload);
}
}
}
组装sps + pps == 1个压缩包数据,存入队列
void VideoChannel::sendSpsPps(uint8_t *sps, uint8_t *pps, int sps_len, int pps_len) {
// 根据协议设置压缩包数据长度
int body_size = 5 + 8 + sps_len + 3 + pps_len;
RTMPPacket *packet = new RTMPPacket; // 开始封包RTMPPacket
RTMPPacket_Alloc(packet, body_size); // 堆区实例化 RTMPPacket
int i = 0;
packet->m_body[i++] = 0x17; // 十六进制转换成二进制,二进制查表 就懂了
packet->m_body[i++] = 0x00; // 重点是此字节 如果是1 帧类型(关键帧 非关键帧), 如果是0一定是 sps pps
packet->m_body[i++] = 0x00;
packet->m_body[i++] = 0x00;
packet->m_body[i++] = 0x00;
// 看图说话
packet->m_body[i++] = 0x01; // 版本
packet->m_body[i++] = sps[1];
packet->m_body[i++] = sps[2];
packet->m_body[i++] = sps[3];
packet->m_body[i++] = 0xFF;
packet->m_body[i++] = 0xE1;
// 两个字节表达一个长度,需要位移
// 用两个字节来表达 sps的长度,所以就需要位运算,取出sps_len高8位 再取出sps_len低8位
//(位运算:https://blog.csdn.net/qq_31622345/article/details/98070787)
// https://www.cnblogs.com/zhu520/p/8143688.html
packet->m_body[i++] = (sps_len >> 8) & 0xFF; // 取高8位
packet->m_body[i++] = sps_len & 0xFF; // 去低8位
memcpy(&packet->m_body[i], sps, sps_len); // sps拷贝进去了
i += sps_len; // 拷贝完sps数据 ,i移位,(下面才能准确移位)
packet->m_body[i++] = 0x01; // pps个数,用一个字节表示
packet->m_body[i++] = (pps_len >> 8) & 0xFF; // 取高8位
packet->m_body[i++] = pps_len & 0xFF; // 去低8位
memcpy(&packet->m_body[i], pps, pps_len); // pps拷贝进去了
i += pps_len; // 拷贝完pps数据 ,i移位,(下面才能准确移位)
// 封包处理
packet->m_packetType = RTMP_PACKET_TYPE_VIDEO; // 包类型 视频包
packet->m_nBodySize = body_size; // 设置好 sps+pps的总大小
packet->m_nChannel = 10; // 通道ID,随便写一个,注意:不要写的和rtmp.c(里面的m_nChannel有冲突 4301行)
packet->m_nTimeStamp = 0; // sps pps 包 没有时间戳
packet->m_hasAbsTimestamp = 0; // 时间戳绝对或相对 也没有时间搓
packet->m_headerType = RTMP_PACKET_SIZE_MEDIUM; // 包的类型:数据量比较少,不像帧数据(那就很大了),所以设置中等大小的包
// packet 存入队列
videoCallback(packet);
}
发送帧信息,把帧类型 RTMPPacket 存入队列
void VideoChannel::sendFrame(int type, int payload, uint8_t *pPayload) {
// 去掉起始码 00 00 00 01 或者 00 00 01
if (pPayload[2] == 0x00){ // 00 00 00 01
pPayload += 4; // 例如:共10个,挪动4个后,还剩6个
// 保证 我们的长度是和上的数据对应,也要是6个,所以-= 4
payload -= 4;
}else if(pPayload[2] == 0x01){ // 00 00 01
pPayload +=3; // 例如:共10个,挪动3个后,还剩7个
// 保证 我们的长度是和上的数据对应,也要是7个,所以-= 3
payload -= 3;
}
// 根据协议设置压缩包数据长度
int body_size = 5 + 4 + payload;
RTMPPacket *packet = new RTMPPacket; // 开始封包RTMPPacket
RTMPPacket_Alloc(packet, body_size); // 堆区实例化 RTMPPacket
// 区分关键帧 和 非关键帧
packet->m_body[0] = 0x27; // 普通帧 非关键帧
if(type == NAL_SLICE_IDR){
packet->m_body[0] = 0x17; // 关键帧
}
packet->m_body[1] = 0x01; // 重点是此字节 如果是1 帧类型(关键帧或非关键帧), 如果是0一定是 sps pps
packet->m_body[2] = 0x00;
packet->m_body[3] = 0x00;
packet->m_body[4] = 0x00;
// 四个字节表达一个长度,需要位移
// 用四个字节来表达 payload帧数据的长度,所以就需要位运算
//(位运算:https://blog.csdn.net/qq_31622345/article/details/98070787)
// https://www.cnblogs.com/zhu520/p/8143688.html
packet->m_body[5] = (payload >> 24) & 0xFF;
packet->m_body[6] = (payload >> 16) & 0xFF;
packet->m_body[7] = (payload >> 8) & 0xFF;
packet->m_body[8] = payload & 0xFF;
memcpy(&packet->m_body[9], pPayload, payload); // 拷贝H264的裸数据
packet->m_packetType = RTMP_PACKET_TYPE_VIDEO; // 包类型,是视频类型
packet->m_nBodySize = body_size; // 设置好 关键帧 或 普通帧 的总大小
packet->m_nChannel = 10; // 通道ID,随便写一个,注意:不要写的和rtmp.c(里面的m_nChannel有冲突 4301行)
packet->m_nTimeStamp = -1; // 帧数据有时间戳
packet->m_hasAbsTimestamp = 0; // 时间戳绝对或相对 用不到,不需要
packet->m_headerType = RTMP_PACKET_SIZE_LARGE ; // 包的类型:若是关键帧的话,数据量比较大,所以设置大包
// 把最终的 帧类型 RTMPPacket 存入队列
videoCallback(packet);
}
当压缩数据加入队列后,开启直播创建的子线程将会获取队列的压缩数据,发送到RTMP服务器。
源码:
NdkPush: 通过RTMP实现推流,直播客户端。
视频推流完成,下一节开始音频推流工作。。。