一、前言
前面我们已经讲解了视频的编码、解码、网络传输的相关基础知识,相信认真阅读多的朋友,应该熟悉了,有人会问,这些知识能够帮我们做什么呢?本篇文章就来说说具体能做那些项目。由于时间和篇幅的关系,先来说说音视频的采集、编码、推流(网络传输),这种应用场景大多在直播,拍摄视频上传服务器等场景。比如通过手机摄像头拍摄了一段很精彩的视频,发送到朋友圈,这个过程就是本文所要详细描述的音视频采集、编码、推流。这里我们就以Android和ios平台讲解,硬件平台就以各类主流手机。
视频会议
二、视频采集
(1)Android视频采集
视频采集
首先打开手机摄像头需要获取系统权限,目前主要是用Cmaera和Camera2这两个类来实现视频的采集,需要打开如下的权限。在Android7.1平台上还需要动态获取权限,所以需要有用户去确认,是否给定权限。动态获取权限的核心代码如下所示。
指明一个需要获取权限的数组,比如需要获取打开摄像头权限、网络权限、存储卡的读写权限、窗口权限等。
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)Android摄像头的打开、预览、预览缓冲设置
通过设置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 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 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)
预览方法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();}
预览时通过什么方式?
这里的预览是通过OpenGL Es,目前使用Android提供的接口,如果需要优化或者适配更多场景,需要从底层设计,后面也会有文章介绍。首先图像的原始YUV像素格式要转换为RGB格式,再把RGB格式的数据渲染到一个纹理,最后才能渲染到屏幕上。整个过程是包括开始预览、刷新、结束。
先讲讲开始预览的流程,如图所示:先准备一个SurfaceView控件来渲染显示,实际的工作还是由EGL和OpenGL ES构造一个渲染线程用于渲染图像。并生成一个纹理ID传回到JAVA层,JAVA层会利用这个ID生成一个Surface-Texture或Surface,这个Surface-Texture或Surface就是作为Camera预览控件。这样就可以把采集到的视频渲染到显示器上。
JAVA层到NATIVE预览流程
再讲讲更新预览的流程,如图所示。当camera开始预览,如何流畅重复预览?当JAVA层调用updateTexture方法,就表示新的视频帧已经传到Native层的纹理ID上,Native层的RenderThread就可以把该纹理ID重新渲染到Activity的界面。就这样实现了更新图像的过程,用户就可以流畅的观看。
JAVA层到NATIVE更新预览流程
最后讲讲结束预览的流程。当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
2.X264编码
为了适应更多的平台,这里先用X264实现软件编码,关于X264具体的接口和源码分析介绍,以后会有文章详解。虽然Android系统提供的MediaCode也可以实现硬编码,实现跨平台,对于应用开发的人来说,无法看清底层的实现原理。以后会有文章来讲解如何用MediaCode实现硬编码或用其它硬编码器实现硬编码。
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; Vpara.rc.i_rc_method = X264_RC_CRF;//控制恒定码率 CRF:恒定码率;CQP:恒定质量;ABR:平均码率 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)
视频编码
(3)如何推流?
目前流媒体广泛应用的协议有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
1.RTMP总流程
首先给IDR帧添加sps和pps,放在IDR帧头部。把头部发送出去,再发送具体的包数据。
//在IDR帧加上SPS和PPSint 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
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);
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.发送PACKET到队列中
把头部和包体数据发送到队列中,实现流控式的发送,防止覆盖数据。
void Send_Rtmp_Packet_To_Quenue(RTMPPacket *pPacket) { mux.lock(); if(Is_Publish){ queue_append_last(pPacket); } mux.unlock();}
5.开启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;}
发送码流 关注"”记录世界 from antonio"
5.编码器资源释放
当编码器退出或者整个应用退出,需要释放空间,清理编码器的资源,防止内存泄漏,导致第二次打开失败。
//清除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);
6.总结
到此为止,整个Adnroid平台的视频采集,编码、推流等底层实现代码就分析完毕,详细你阅读完一定对底层有了新的认识,当涉及到编码参数设置,比如有些字段不了解,可以看看前面的文章。我会在后面的文章写出音频采集,编码,推流,敬请关注。欢迎大家关注,并转发给身边的朋友,谢谢。
本文来自投稿,不代表本人立场,如若转载,请注明出处:http://www.sosokankan.com/article/2257225.html