一、前言
前面我们已经讲解了视频的编码、解码、网络传输的相关基础知识,相信认真阅读多的朋友,应该熟悉了,有人会问,这些知识能够帮我们做什么呢?本篇文章就来说说具体能做那些项目。由于时间和篇幅的关系,先来说说音视频的采集、编码、推流(网络传输),这种应用场景大多在直播,拍摄视频上传服务器等场景。比如通过手机摄像头拍摄了一段很精彩的视频,发送到朋友圈,这个过程就是本文所要详细描述的音视频采集、编码、推流。这里我们就以Android和ios平台讲解,硬件平台就以各类主流手机。
二、视频采集
2.1.Android视频采集
首先打开手机摄像头需要获取系统权限,目前主要是用Cmaera和Camera2这两个类来实现视频的采集,需要打开如下的权限。在Android7.1平台上还需要动态获取权限,所以需要有用户去确认,是否给定权限。动态获取权限的核心代码如下所示。
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
指明一个需要获取权限的数组,比如需要获取打开摄像头权限、网络权限、存储卡的读写权限、窗口权限等。
private static String[] REQUEST_PERMISSIONS_ARRAY = {
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE",
"android.permission.INTERNET",
"android.permission.ACCESS_NETWORK_STATE",
"android.permission.CAMERA",
"android.hardware.camera",
"android.hardware.camera.autofocus",
"android.permission.SYSTEM_ALERT_WINDOW"};
private static final int REQUEST_PERMISSIONS = 1;
再完成检查权限是否拥有和申请权限
public static void verifyPermissions(Activity activity)
{
try {
//检测是否有写的权限
int permission = ActivityCompat.checkSelfPermission(activity, "android.permission.CAMERA");
if (permission != PackageManager.PERMISSION_GRANTED)
{
// 没有写的权限,去申请写的权限,会弹出对话框
ActivityCompat.requestPermissions(activity,REQUEST_PERMISSIONS_ARRAY,REQUEST_PERMISSIONS);
}
} catch (Exception e) {
e.printStackTrace();
}
}
最后用onCreate方法去实现调用,如下图所示:
@Overrideprotected void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
verifyPermissions(MainActivity.this);
}
2.2Android摄像头的打开、预览、预览缓冲设置
通过设置ID获取摄像头属性,并打开摄像头。这里的摄像头ID包括前置和后置摄像头,前置摄像ID是Camera.CameraInfo.CAMERA_FACING_FRONT,后置摄像头ID是Camera.CameraInfo.CAMERA_FACING_BACK。VParam是自己封装的视频相关参数设置。这里的尺寸暂时就设定为1920X1080,你也可以设置其它预览大小。
打开摄像头后,就可以获取摄像头相关参数,并设置参数,如预览像素格式,预览图片大小,预览缓冲区的大小等。注意这里一定要设置预览大小,否则图片会被拉伸,图片会被放大,这里是一个坑。
//获取摄像头
IDmcamera = Camera.open(VParam.getCameraId());
//获取参数
Camera.Parameters Cpara = mcamera.getParameters();
//这里是设置预览像素格式,如果预览格式与编码的像素格式不一致,就要 //像素格式转换
//关于像素格式的文章,后面会有文章详细介绍 Cpara.setPreviewFormat(ImageFormat.NV21);
//获取系统支持的预览图片大小,后面会有方法SelectOptionSize介绍怎样匹配最合适的图片大小
List<Camera.Size> SupportPreSize = parameters.getSupportedPreviewSizes(); Cpara.setPictureSize(SelectOptionSize(SupportPreSize,VParam.getWidth(),VParam.getHeight()).width,SelectOptionSize(SupportPreSize,VParam.getWidth(),VParam.getHeight()).height);
//注意这里一定要设置预览大小,否则图片会被拉伸,图片会被放大,这里/是一个**坑** Cpara.setPreviewSize(SelectOptionSize(SupportPreSize,VParam.getWidth(),VParam.getHeight()).width,SelectOptionSize(SupportPreSize,VParam.getWidth(),VParam.getHeight()).height);
//给camera类配置参数
mcamera.setParameters(Cpara);
//这里是设置横竖屏,暂停设定为横屏
//竖屏是90°
mcamera.setDisplayOrientation(0);
//预览的图片或视频,需要在surfaceview上显示,这个surface就是由外面创建,然后传递过来 mcamera.setPreviewDisplay(surfaceHolder);
//分配回调摄像头空间大小,这个空间就是用来缓存预览视频大小 //这个大小根据视频的长、宽和像素格式来计算
prevBuffer = new byte[videoParam.getWidth() * videoParam.getHeight()*3];
//把prevBuffer 设置为CallbackBuffer mcamera.addCallbackBuffer(prevBuffer );
//这个方法表示与上面配套使用,表示当前对象使用这个buffer mcamera.setPreviewCallbackWithBuffer(this);
//开启预览
mcamera.startPreview();
接下来就说说方法SelectOptionSize,如何去匹配合适的预览图片大小。
private static Camera.Size SelectOptionSize( List<Camera.Size> sizes, int w, int h) {
final double ASPECT_TOLERANCE = 0.1;
//这里适合横屏,竖屏预览,这里可能需要修改
double targetRatio = (double) w / h;
Camera.Size OptionSize = null;
double minDiff = Double.MAX_VALUE;
int targetHeight = h;
//遍历系统支持的大小,找出最合适
for (Camera.Size size : sizes) {
double ratio = (double) size.width / size.height;
if (Math.abs(ratio - targetRatio) > ASPECT_TOLERANCE)
continue;
if (Math.abs(size.height - targetHeight) < minDiff) { OptionSize = size;
minDiff = Math.abs(size.height - targetHeight);
}
}
//是否是第一次匹配
if (OptionSize == null) {
minDiff = Double.MAX_VALUE;
for (Camera.Size size : sizes) {
if (Math.abs(size.height - targetHeight) < minDiff) { OptionSize = size;
minDiff = Math.abs(size.height - targetHeight);
}
}
}
return OptionSize;
}
预览方法startPreview()需要在surface被创建的时候就调用,就能够立即渲染出来。
@Overridepublic void surfaceCreated(SurfaceHolder holder) {
startPreview();}
那现在采集和渲染视频都有了,怎样把采集的视频数据送入编码器呢?这里是一个关键的步骤。需要调用这个方法。这里有个坑,就是不能有耗时操作去阻塞,最好是再开一个线程去做耗时操作,否则可能只会回调一次,导致重复编码一帧数据。比如,我这里就开了一个线程去做底层编码推流的耗时操作。
@Override public void onPreviewFrame(byte[] data, Camera camera) {
mcamera.addCallbackBuffer(prevBuffer);
}
@Overridepublic void startPush() {
isPushing = true;
new Thread(new Runnable() {
@Override
public void run() {
CameraUtil.pushCmeraData(previewBuffer);
}
}).start();
}
2.3预览时通过什么方式?
这里的预览是通过OpenGL Es,目前使用Android提供的接口,如果需要优化或者适配更多场景,需要从底层设计,后面也会有文章介绍。首先图像的原始YUV像素格式要转换为RGB格式,再把RGB格式的数据渲染到一个纹理,最后才能渲染到屏幕上。整个过程是包括开始预览、刷新、结束。
先讲讲开始预览的流程,如图所示:先准备一个SurfaceView控件来渲染显示,实际的工作还是由EGL和OpenGL ES构造一个渲染线程用于渲染图像。并生成一个纹理ID传回到JAVA层,JAVA层会利用这个ID生成一个Surface-Texture或Surface,这个Surface-Texture或Surface就是作为Camera预览控件。这样就可以把采集到的视频渲染到显示器上。
再讲讲更新预览的流程,如图所示。当camera开始预览,如何流畅重复预览?当JAVA层调用updateTexture方法,就表示新的视频帧已经传到Native层的纹理ID上,Native层的RenderThread就可以把该纹理ID重新渲染到Activity的界面。就这样实现了更新图像的过程,用户就可以流畅的观看。
最后讲讲结束预览的流程。当Surface被销毁时,调用surfaceDestroyed方法,就可以调用stopPreview()方法渲染。如图所示:
@Overridepublic void surfaceDestroyed(SurfaceHolder holder) {
stopPreview();
}
stopPreview()方法的具体实现如下:
private void stopPreview() {
if(mcamera != null){
//camera停止预览
mcamera.stopPreview();
//camera预览回调释放缓冲区
mcamera.setPreviewCallback(null);
//camera释放资源
mcamera.release();
mcamera = null;
}
}
3.1.像素格式转换
前文讲解了Camera采集视频的像素格式是NV21,底层的编码的像素格式为YUV420SP,这里就涉及到像素格式转换。具体代码实现如下文所示,具体原理以后的文章会讲解。注意:像素格式转换模块的代码最好在Native层去实现,在JAVA层会带来性能开销较大。
void NV21TOYUV420SP(const unsigned char *Yuv_Src,const unsigned char *Yuv_Dst,int yData){
int UvData = 0;
int uData = 0;
unsigned char *Nv = NULL;
unsigned char *Yuv = NULL;
int change_count=0;
//Y像素直接拷贝
memcpy(Yuv_Dst,Yuv_Src,yData);
//U、V像素位置
UvData = yData>>1;
uData = UvData>>1;
//U、V像素拷贝
memcpy(Yuv_Dst+yData,Yuv_Src+yData+1,UvData-1);
Nv = Yuv_Src+yData; Yuv = Yuv_Dst+yData+1;
//交换YUV420SP与NV21的UV地址
while(count<uData) {
(*Yuv)=(*Nv);
Yuv+=2;
Nv+=2;
++change_count;
}
}
3.2.X264编码
为了适应更多的平台,这里先用X264实现软件编码,关于X264具体的接口和源码分析介绍,以后会有文章详解。虽然Android系统提供的MediaCode也可以实现硬编码,实现跨平台,对于应用开发的人来说,无法看清底层的实现原理。以后会有文章来讲解如何用MediaCode实现硬编码或用其它硬编码器实现硬编码。
3.2.1.编码参数设置及打开编码器
//X264输入和输出图像
x264_picture_t picture_in;
x264_picture_t picture_out;
//编码器句柄
x264_t *video_encode_handle;
x264_param_t Vpara; yData = width * height;
Uv_Data = yData/4;
//默认设置
x264_param_default_preset(&Vpara, "ultrafast", "zerolatency");
//YUV420SP,底层设置像素格式
Vpara.i_csp = X264_CSP_NV12;
//设置视频的宽和高,这个可以开放接口给JAVA层去设置
//设置的宽高和编码的视频宽高需要一致,否则可能会出现问题,比如底部有绿边的问题
Vpara.i_width = width;
Vpara.i_height = height;
//码率通过fps控制
Vpara.b_vfr_input = 0;
//还记得前面有文章讲解的SPS和PPS,如果忘记,可以去看前面的文章 //每帧写sps和pps,也可以只在头部写。
Vpara.b_repeat_headers = 1;
//每帧传入sps和pps,提高视频纠错能力
Vpara.i_level_idc = 51;
//控制恒定码率 CRF:恒定码率;CQP:恒定质量;ABR:平均码率
Vpara.rc.i_rc_method = X264_RC_CRF;
//码率
Vpara.rc.i_bitrate = bitRate;
//瞬间最大码率
Vpara.rc.i_vbv_max_bitrate = (int) (bitRate * 1.2);
Vpara.i_fps_num = (uint32_t) frameRate;
//帧率分子
Vpara.i_fps_den = 1;
//帧率分母
Vpara.i_timebase_num = param.i_fps_den;
//时间基分子
Vpara.i_timebase_den = param.i_fps_num;
//时间基分母
Vpara.i_threads = 1;
//编码线程数
//设置profile档次,"baseline"代表没有B帧 x264_param_apply_profile(&Vpara, "baseline");
//初始化图像
x264_picture_alloc(&picture_in, Vpara.i_csp, Vpara.i_width, Vpara.i_height);
//打开编码器
video_encode_handle = x264_encoder_open(&Vpara); if(video_encode_handle){
} else{
throw_error_to_java(OPEN_VIDEOENCODER_FAILED);
}
前面把像素格式转换后,需要把数据拷贝到Frmae_in,拷贝完了,就可以送入编码器。
memcpy(picture_in, Yuv_Dst, Y_Data);
//还记得前面讲的h264编码的单元吧
x264_nal_t *h264_nal = NULL;
//单元个数
int h264_nal _num = -1;
//正式开始编码,这里的h264_nal就是NAL单元,前面要文章详解,所以学习理论知识还是很有用
//Frame_out就是编码后的图像
//这个接口虽然简单,但是有很多做了工作,以后会有很多文章介绍
x264_encoder_encode(V_encode_handle, &h264_nal , &h264_nal _num, &Frame_in, & Frame_out)
目前流媒体广泛应用的协议有RTMP、RTSP、RTP、HTTP等,本文主要是讲解RTMP,暂时不做详细的理论介绍,讲解在工程中如何使用。使用RTMP推流。关于更多网络传输知识,请阅读这篇文章。
[https://mp.weixin.qq.com/s?__biz=MzU2MDU4OTk3Mw==&mid=2247483805&idx=1&sn=20588e2e3a0be0e739b7f1f1e741e7eb&chksm=fc04fc67cb737571ea5cf05e0e5abbc661796b52e5226ddb540b8b66f01dd1aa8332918d3232&scene=21#wechat_redirect]
4.1.RTMP总流程
首先给IDR帧添加sps和pps,放在IDR帧头部。把头部发送出去,再发送具体的包数据。
//在IDR帧加上SPS和PPS
int Sps_Len = 0, Pps_Len = 0;
unsigned char Sps[100];
unsigned char Pps[100];
memset(Sps, 0, 100);
memset(Pps, 0, 100);
for (i = 0; i < nal_num; ++i) {
if(nal[i].i_type == NAL_SPS){
//sps
Sps_Len = nal[i].i_payload - 4;
memcpy(Sps, nal[i].p_payload + 4, Sps_Len);
} else if(nal[i].i_type == NAL_PPS){
//pps
Pps_Len = nal[i].i_payload - 4;
memcpy(Pps, nal[i].p_payload + 4,Pps_Len);
Send_x264_header(Sps, Pps, Sps_Len, Pps_Len);
} else{
Send_x264_Packet(nal[i].p_payload, nal[i].i_payload);
}
}
4.2.添加包头的SPS和PPS信息
//头部数据的长度
int Packet_size = 16 + Sps_Len + Pps_Len;
//给packet分配内存和初始化
RTMPPacket *packet = malloc(sizeof(RTMPPacket));
RTMPPacket_Alloc(packet, Packet_size);
RTMPPacket_Reset(packet);
unsigned char* body = (unsigned char *) packet->m_body;
int i = 0;
//每个字段的含义
//VideoHeadType 0-3:
//FrameType(KeyFrame=1);
//4-7:CodecId(AVC=7)
body[i++] = 0x17;
////AVC 包类型
body[i++] = 0x00;
body[i++] = 0x00;
body[i++] = 0x00;
body[i++] = 0x00;
//AVC 解码器配置
body[i++] = 0x01;
body[i++] = sps[1];
body[i++] = sps[2];
body[i++] = sps[3];
body[i++] = 0xFF;
//sps信息
body[i++] = 0xE1;
body[i++] = (unsigned char) ((sps_len >> 8) & 0xFF);
body[i++] = (unsigned char) (sps_len & 0xFF);
memcpy(&body[i], sps, (size_t) sps_len);
i += sps_len;
//pps信息
body[i++] = 0x01;
body[i++] = (unsigned char) ((pps_len >> 8) & 0xFF);
body[i++] = (unsigned char) (pps_len & 0xFF);
memcpy(&body[i], pps, (size_t) pps_len);
i += pps_len;
//Video包
packet->m_packetType = RTMP_PACKET_TYPE_VIDEO;
packet->m_nBodySize = (uint32_t) body_size;packet->m_nTimeStamp =0;
packet->m_hasAbsTimestamp = 0;packet->m_nChannel = 0x04;
packet->m_headerType = RTMP_PACKET_SIZE_MEDIUM;
//添加到RTMP包到队列中
Send_Rtmp_Packet_TO_Quenue(packet);
4.3.添加包体
这个就是真正的包体数据,这个发送到服务器了。
//是否是起始帧识别,前面的文章也分析过
if(buf[2] == 0x01){
//00 00 01
buf += 3;
len -= 3;
} else if (buf[3] == 0x01){
//00 00 00 01
buf += 4;
len -= 4;
}int body_size = len + 9;
RTMPPacket *packet = malloc(sizeof(RTMPPacket));
RTMPPacket_Alloc(packet, body_size);
RTMPPacket_Reset(packet);
unsigned char *body = (unsigned char *) packet->m_body;
int type = buf[0] & 0x1F;
//如果是IDR帧
if(type == NAL_SLICE_IDR){
body[0] = 0x17;
} else{
body[0] = 0x27;
}
//配置包体信息
body[1] = 0x01;
body[2] = 0x00;
body[3] = 0x00;
body[4] = 0x00;
body[5] = (unsigned char) ((len >> 24) & 0xFF);
body[6] = (unsigned char) ((len >> 16) & 0xFF);
body[7] = (unsigned char) ((len >> 8) & 0xFF);
body[8] = (unsigned char) (len & 0xFF);
memcpy(&body[9], buf, (size_t) len);
packet->m_headerType = RTMP_PACKET_SIZE_LARGE;
packet->m_hasAbsTimestamp = 0;
packet->m_nBodySize = (uint32_t) body_size;
packet->m_nChannel = 0x04;
//包类型
packet->m_packetType = RTMP_PACKET_TYPE_VIDEO;
//时间戳
packet->m_nTimeStamp = RTMP_GetTime() - start_time;
//添加发送包体到队列中
Send_Rtmp_Packet_To_Quenue(packet);
4.4发送PACKET到队列中
把头部和包体数据发送到队列中,实现流控式的发送,防止覆盖数据。
void Send_Rtmp_Packet_To_Quenue(RTMPPacket *pPacket) {
mux.lock();
if(Is_Publish){
queue_append_last(pPacket);
}
mux.unlock();
}
五、开启RTMP推流线程
这一步是非常重要,开启推流线程,就是采集,编码,推流的最后一部分。详细解释如下:
完成这部分,java端就可以实现调用Native完成整个项目的工作了。
//推流线程
void *Send_Stream_Thread(void * args){
bool isPushing = false;
//RTMP分配空间
RTMP* pRtmp = RTMP_Alloc();
if(!pRtmp){
goto end;
}
//初始化rtmp
RTMP_Init(pRtmp);
//设置需要推流的地址,并与服务器连接
RTMP_SetupURL(pRtmp, url_path);
//使能RTMP推流
RTMP_EnableWrite(pRtmp);
//设置超时时间,就是如果到了超时时间没有连接上,就断开
rtmp->Link.timeout = 500000;
if(!RTMP_Connect(pRtmp, NULL)){
Throw_Error_To_Java(RTMP_CONNECT_ERROR);
goto end;
}
if(!RTMP_ConnectStream(pRtmp, 0)){
Throw_Error_To_Java(RTMP_CONNECT_STREAM_ERROR);
goto end;
}
//初始时间,开始计时
start_time = RTMP_GetTime();
isPushing = TRUE;
//推流真正开始
while( isPushing) {
mux.lock()
//从队列中取出第一个RTMP包
RTMPPacket *pPacket = queue_get_first();
if(pPacket){
//从队列中删除第一个包
queue_delete_first();
//发送rtmp包,true表示rtmp空间有缓存
int ret = RTMP_SendPacket(rtmp, packet, TRUE);
if(!ret){
//如果失败 释放内存
RTMPPacket_Free(packet);
mux.unlock();
//向java抛出异常
Throw_Error_To_Java(SEND_RTMP_PACKAT_ERROR);
goto end;
}
//防止内存泄漏
RTMPPacket_Free(packet);
}
mux.unlock();
}
end:
RTMP_Close(pRtmp);
free(pRtmp);
free(url_path);
return 0;}
当编码器退出或者整个应用退出,需要释放空间,清理编码器的资源,防止内存泄漏,导致第二次打开失败。
//清除x264的picture缓存x264_picture_clean(&Frame_in);x264_picture_clean(&Frame_out);
//关闭视频编码器
x264_encoder_close(V_encode_handle);
//删除全局引用
(*env)->DeleteGlobalRef(env, jobject_error);
(*javaVM)->DestroyJavaVM(javaVM);
//退出线程
pthread_exit(0);
六、总结
到此为止,整个Adnroid平台的视频采集,编码、推流等底层实现代码就分析完毕,详细你阅读完一定对底层有了新的认识,当涉及到编码参数设置,比如有些字段不了解,可以看看前面的文章。我会在后面的文章写出音频采集,编码,推流,敬请关注。欢迎大家关注,并转发给身边的朋友,谢谢。
欢迎扫描二维码关注,同步更新,后面会有源码链接,欢迎交流学习。
扫扫,加关注