制作一个视频录制器

API简介

此文将介绍如何使用AudioRecord,Camera2,Surface,MediaCodec来制作一个视频录制器。

  • AudioRecord 用于录制音频,在此文中我用它来录制声音并输出PCM数据。它本身也支持其他格式的比如mp3
  • Camera2 用于录制视频,它录制的视频数据可以通过surface获取
  • MediaCodec 用来编码音视频,此文中会将音频编码为AAC数据流,将视频编码为H264数据流
  • MediaMuxer 此文中用来将编码好的AAC数据流,和H264数据流合并封装成Mp4文件

流程简介

  1. 用户打开页面时,我们打开相机预览,摄像机采集到的数据通过SurfaceView来展示给用户。
  2. 用户点击开始录制,我们通过Camera2的Api向相机服务发送录制Request,录制的数据关联在我们传给相机的Surface中。这里录制数据的surface和预览数据的surface是分开的。
  3. 开始录制的同时,我们会通过AudioRecoder.startRecording启动音频录制,并启动音频编码线程和视频编码线程。编码线程中将通过MediaCodec进行编码。
  4. 开始录制的同时,我们我们也会创建MediaMuxer。
  5. 编码线程收到编码好的数据后将数据会塞给MediaMuxer。

核心代码片段

预览

  • 我们使用的Camer2的API,通过context.getSystemService(AppCompatActivity.CAMERA_SERVICE) as CameraManager可以获取到相机服务。
  • 可以通过CameraManager获取到手机支持的相机id列表,并获取相机的信息如相机是前置还是后置,相机支持的输出格式、支持的输出fps,支持的输出大小等等,这些信息都可以通过CameraCharacteristics类获取
  • 我们可以先枚举出所有的相机,并获取他们的信息从而选择合适的相机进行打开。
data class CameraInfo(val cameraId: String? = null,// 前置还是后置val lenFacing: Int = -1,// 输出的数据旋转角度val orientation: Int? = null
)
// 枚举所有相机,想关注的特性封装到CameraInfo中
fun enumerateCameras(cameraManager: CameraManager): ArrayList<CameraInfo> {val cameraInfoList = arrayListOf<CameraInfo>()try {val cameraIdList = cameraManager.cameraIdListfor (cameraId in cameraIdList) {val cameraCharacteristics =cameraManager.getCameraCharacteristics(cameraId) ?: return cameraInfoListval lensFacing = cameraCharacteristics.get(CameraCharacteristics.LENS_FACING)val capabilities =cameraCharacteristics.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES)val orientation = cameraCharacteristics.get(CameraCharacteristics.SENSOR_ORIENTATION)cameraInfoList.add(CameraInfo(cameraId, lensFacing ?: -1,orientation))}} catch (e: CameraAccessException) {e.printStackTrace()}return cameraInfoList} // 根据自己的情况选择合适的相机private fun findBestCameraInfo(cameraInfoList: ArrayList<CameraInfo>): CameraInfo {var cameraInfo: CameraInfo? =cameraInfoList.first { it.lenFacing == CameraCharacteristics.LENS_FACING_FRONT }if (cameraInfo == null) {cameraInfo = cameraInfoList.first()}return cameraInfo}
  • 选择好相机之后我们就可以进行预览。此时我们可以根据自身View的大小结合相机支持的大小选择合适的大小预览。同时可以对最终选择的大小计算出宽高比,并调整SurfaceView的宽高比。如果不调整图像会有拉伸的问题。
    选择支持合适的预览大小的代码为:
  /**- @param display 窗口的大小信息,根据此信息结合相机支持的大小选择合适的Size*/fun getLargestPreviewSize(display: Display): SmartSize {mCurCameraInfo ?: SmartSize.SIZE_NONEval cameraCharacteristics = mCameraManager.getCameraCharacteristics(mCurCameraInfo?.cameraId ?: return SmartSize.SIZE_NONE)val displayPoint = Point()display.getRealSize(displayPoint)val screenSize = SmartSize(displayPoint.x, displayPoint.y)var hdScreen = falseif (screenSize.width >= SmartSize.SIZE_1080P.width|| screenSize.height >= SmartSize.SIZE_1080P.height) {hdScreen = true}val maxSize = if (hdScreen) SmartSize.SIZE_1080P else screenSizeval map = cameraCharacteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)// camera2的API在获取一些信息,它需要传入你最终会讲数据关联哪个类。可见它对不同的类支持的格式/大小等可能不一样// 这里SurfaceHold是为了预览的SurfaceViewval surfaceOutputSizes =map?.getOutputSizes(SurfaceHolder::class.java) ?: return SmartSize.SIZE_NONE// 因为我们是直接将输出的surface交给MediaCodedc所以最终选择的大小也需要MediaCodec支持val mediacodecOutputSizes =map.getOutputSizes(MediaCodec::class.java) ?: return SmartSize.SIZE_NONEval outputSizes = surfaceOutputSizes and mediacodecOutputSizesoutputSizes.sortByDescending { it.width * it.height }val targetSize =outputSizes.first { it.width <= maxSize.width && it.height <= maxSize.height }return SmartSize(targetSize.width, targetSize.height)}

SurfaceView适配宽高比代码为:

class AutoFitSurfaceView @JvmOverloads
constructor(context: Context? = null, attributeSet: AttributeSet? = null, defStyle: Int = 0) :SurfaceView(context, attributeSet, defStyle) {private var aspectRatio: Float = 0Ffun setAspectRatio(width: Int, height: Int) {this.aspectRatio = width.toFloat() / height.toFloat()holder.setFixedSize(width, height)requestLayout()}override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {super.onMeasure(widthMeasureSpec, heightMeasureSpec)val width = MeasureSpec.getSize(widthMeasureSpec)val height = MeasureSpec.getSize(heightMeasureSpec)if (aspectRatio == 0f) {setMeasuredDimension(width, height)} else {val actualRatio = if (width > height) aspectRatio else 1f / aspectRatioval newWidth: Intval newHeight: Intif (width < height * actualRatio) {newHeight = heightnewWidth = (height * actualRatio).roundToInt()} else {newWidth = widthnewHeight = (width / actualRatio).roundToInt()}setMeasuredDimension(newWidth, newHeight)}}
}
  • 根据选择好的cameraId打开相机
   mCameraManager.openCamera(mCurCameraInfo?.cameraId ?: return, object : CameraDevice.StateCallback() {override fun onOpened(camera: CameraDevice) {// 打开成功得到CameraDevicecameraOpened(camera)}override fun onDisconnected(camera: CameraDevice) {}override fun onError(camera: CameraDevice, error: Int) {}},cameraHandler)
  • 创建一个Session,并将预览用的surface和录视频用的surface传入

    • 预览的surface指的是SurfaceView关联的surface
    • 录视频用的surface我有MediaCodec来创建,最终也会传给视频MediaCodec用于编码的输入源。此外这里用MediaCodec创建的Surface需要先设置给MediaCodec并关联的MediaCodec需要进行configure,否则在录制时会报错。
    private val videoCodecInputSurface: Surface by lazy {val surface = MediaCodec.createPersistentInputSurface()MediaFoundationFactory.createVideoMediaCodec(videoMediaFormat, surface)surface}// MediaFoundationFactory.ktfun createVideoMediaCodec(format: MediaFormat, inputSurface: Surface): MediaCodec {val mediaCodecList = MediaCodecList(MediaCodecList.ALL_CODECS)val codecName = mediaCodecList.findEncoderForFormat(format)val videoCodec = MediaCodec.createByCodecName(codecName)videoCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)videoCodec.setInputSurface(inputSurface)return videoCodec}
private fun cameraOpened(camera: CameraDevice) {try {camera.createCaptureSession(arrayListOf(previewSurface, videoCodecInputSurface),object : CameraCaptureSession.StateCallback() {override fun onConfigured(session: CameraCaptureSession) {// session 打开成功mCameraCaptureSession = sessionstartPreview(session)}override fun onConfigureFailed(session: CameraCaptureSession) {}},cameraHandler)} catch (e: CameraAccessException) {e.printStackTrace()} catch (e: Exception) {e.printStackTrace()}}
  • 向创建的session提交预览请求,体检完之后SurfaceView就可以看到相机采集到的数据了
    private val captureRequest: CaptureRequest? by lazy {val builder =mCameraCaptureSession.device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)builder.addTarget(previewSurface)builder.set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, Range(FPS, FPS))builder.build()}private fun startPreview(session: CameraCaptureSession) {try {session.setRepeatingRequest(captureRequest ?: return,null,cameraHandler)} catch (e: CameraAccessException) {e.printStackTrace()}}

录制

  • 创建MediaMuxer,在编码器得到编码数据后将使用此对象进行写入
  muxer = MediaMuxer(output, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)// 可以根据相机输出数据的origintation 来设置角度muxer.setOrientationHint(orientation)
  • 创建视频编码器
    private const val FRAME_RATE = 15private const val IFRAME_INTERVAL = 10private const val VIDEO_BIT_RATE = 2000000private const val AUDIO_BIT_RATE = 128000fun createVideoMediaFormat(width: Int, height: Int): MediaFormat {val mediaFormat = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC,// h264width, height)mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT,MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface // 因为我的输入数据是直接从surface获取,所以这样设置)// 预期编码后的比特率mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, VIDEO_BIT_RATE)// 帧率mediaFormat.setInteger(MediaFormat.KEY_FRAME_RATE, FRAME_RATE)// 每隔多少帧插入一个I帧mediaFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, IFRAME_INTERVAL)return mediaFormat}fun createVideoMediaCodec(format: MediaFormat, inputSurface: Surface): MediaCodec {val mediaCodecList = MediaCodecList(MediaCodecList.ALL_CODECS)val codecName = mediaCodecList.findEncoderForFormat(format)val videoCodec = MediaCodec.createByCodecName(codecName)videoCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)videoCodec.setInputSurface(inputSurface)return videoCodec}
  • 创建音频编码器
    private const val SAMPLE_RATE = 44100private const val CHANNEL_COUNT = 2fun createAudioMediaFormat(): MediaFormat {val mediaFormat = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC,SAMPLE_RATE,CHANNEL_COUNT)// 设置预期比特率mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, AUDIO_BIT_RATE)mediaFormat.setInteger(MediaFormat.KEY_AAC_PROFILE,MediaCodecInfo.CodecProfileLevel.AACObjectELD)return mediaFormat}fun createAudioMediaCodec(format: MediaFormat): MediaCodec {val mediaCodecList = MediaCodecList(MediaCodecList.ALL_CODECS)val codecName = mediaCodecList.findEncoderForFormat(format)val audioCodec = MediaCodec.createByCodecName(codecName)audioCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)return audioCodec}
  • 创建AudioRecord用来录音
    fun createAudioRecord(audioFormat: AudioFormat): AudioRecord {val minBufferSize = AudioRecord.getMinBufferSize(44100,AudioFormat.CHANNEL_IN_STEREO, AudioFormat.ENCODING_PCM_16BIT)return AudioRecord.Builder().setAudioFormat(audioFormat).setBufferSizeInBytes(minBufferSize * 2).setAudioSource(MediaRecorder.AudioSource.DEFAULT).build()}
  • 开始录音与结束录音
   mAudioRecord.startRecording()mAudioRecord.stop()
  • 视频编码

    • 因为我在创建编码器的时候设置了输入为surface,所以编码的地方我只需要获取数据就行了。
      设置输入surface的代码为:
    videoCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
    videoCodec.setInputSurface(inputSurface)
    
    • 开始编码的时候需要使用
    mMediaCodec.start()
    
    • 将编码好的数据使用muxer写入mp4文件时需要传入pts用来做音视频同步,负责播放时声音和视频会不同步
        while (state == STATE_START) {val bufferInfo = MediaCodec.BufferInfo()val outputIndex = videoCodec.dequeueOutputBuffer(bufferInfo, TIME_OUT)if (outputIndex >= 0) {val outputBuffer = videoCodec.getOutputBuffer(outputIndex) ?: continueif (timeSync.audioUpdated()) {// 音视频pts同步,视频编码用surface模式无法自定义pts所以,我的解决方式在获得到第一个音频数据和第一个视频数据时计算音频pts和视频pts的diff. 之后的编码数据对于视频数据都加上之前计算的diff,从而实现同步bufferInfo.presentationTimeUs =timeSync.getVideoPts(bufferInfo.presentationTimeUs) muxerMp4.writeVideoSampleData(outputBuffer, bufferInfo)}videoCodec.releaseOutputBuffer(outputIndex, false)Log.i(TAG, "consume output buffer index$outputIndex")} else if (outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {muxerMp4.addVideoTrack(videoCodec.getOutputFormat())}}
  • 音频编码

    • 音频编码的逻辑根视频编码类似
    • 开始编码时都需要使用mMediaCodec.start()
        while (state == STATE_START) {val inputBufferIndex = audioCodec.dequeueInputBuffer(TIME_OUT)if (inputBufferIndex >= 0) {val inputBuffer = audioCodec.getInputBuffer(inputBufferIndex) ?: return// 读取录制好的音频数据val size = audioRecord.read(inputBuffer, inputBuffer.limit())var end = falseif (size <= 0) {end = audioRecord.recordingState == AudioRecord.RECORDSTATE_STOPPED}// 向编码器输入音频裸数据,并传入ptsaudioCodec.queueInputBuffer(inputBufferIndex, 0, size, timeSync.getAudioPts(),if (end) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0)}val bufferInfo = MediaCodec.BufferInfo()val outputBufferIndex = audioCodec.dequeueOutputBuffer(bufferInfo, TIME_OUT)if (outputBufferIndex >= 0) {val outputBuffer = audioCodec.getOutputBuffer(outputBufferIndex) ?: return// 读取编码后的数据,通过mediaMuxer写入mp4文件muxerMp4.writeAudioSampleData(outputBuffer, bufferInfo)audioCodec.releaseOutputBuffer(outputBufferIndex, false)} else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {Log.i(TAG, "run: outputBufferIndex:${outputBufferIndex}")muxerMp4.addAudioTrack(audioCodec.getOutputFormat())}}
  • 时间同步逻辑

    • 我的同步逻辑比较粗暴,视频编码的数据我会等到第一个音频编码数据拿到之后再写入。之后我在拿到第一针视频数据后跟第一个音频数据的pts算出diff。之后的视频数据我都会在视频pts的基数上加上这个diff.
class TimeSync {private var fistAudioPts: Long? = nullprivate var diff: Long? = nullfun getAudioPts(): Long {val time = currentMicrosecond()if (fistAudioPts == null) {fistAudioPts = time}return time}fun audioUpdated(): Boolean = fistAudioPts != nullfun getVideoPts(pts: Long): Long {if (diff == null) {diff =  pts - fistAudioPts!!}return pts+ diff!!}private fun currentMicrosecond() = System.nanoTime() / 1000
}
  • MediaMuxer使用时需要注意

    • 需要等到获取了音频/视频的outputFormat之后在使用 muxer.addTrack(videoMediaFormat)加入track
    • 等音频和视频的都加入tracker之后再调用muxer.start()
    • muxer.start之后在写入音频编码数据和视频编码数据
class MediaMuxerMp4(output: String, orientation: Int) {private val muxer: MediaMuxerprivate var audioTrackIndex: Int? = nullprivate var videoTrackIndex: Int? = nullprivate var audioReady = falseprivate var videoReady = falseprivate var isStarted = falsecompanion object {private const val TAG = "MediaMuxerMp4"}init {muxer = MediaMuxer(output, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)muxer.setOrientationHint(orientation)}fun addAudioTrack(audioMediaFormat: MediaFormat) {if (audioTrackIndex == null) {audioTrackIndex = muxer.addTrack(audioMediaFormat)audioReady = truetryToStart()}}fun addVideoTrack(videoMediaFormat: MediaFormat) {if (videoTrackIndex == null) {videoTrackIndex = muxer.addTrack(videoMediaFormat)videoReady = truetryToStart()}}fun writeAudioSampleData(byteBuffer: ByteBuffer, bufferInfo: MediaCodec.BufferInfo) {if (isStarted) {muxer.writeSampleData(audioTrackIndex ?: return, byteBuffer, bufferInfo)}}fun writeVideoSampleData(byteBuffer: ByteBuffer, bufferInfo: MediaCodec.BufferInfo) {if (isStarted) {muxer.writeSampleData(videoTrackIndex ?: return, byteBuffer, bufferInfo)}}private fun tryToStart() {if (audioReady && videoReady) {muxer.start()isStarted = true}}fun stop() {muxer.stop()isStarted = falseaudioReady = falsevideoReady = false}fun release() {muxer.release()}
}

资源的释放

  • 编码器的停止与释放
 fun destroy() {mMediaCodec.stop()mMediaCodec.release()}
  • MediaMuxer的释放
        muxer.release()
  • 视频编码器surface的释放
        videoCodecInputSurface.release()
  • audioRecord的释放
        mAudioRecord.release()

参考

Camera2相关API
MediaCodec
MediaMuxer

Android制作一个视频录制器相关推荐

  1. Android制作简易的调色器,并实现复制色值的功能

    Android制作简易的调色器,并实现复制色值的功能 我们上课老师让做的作业,参照别人的代码,可能不够完善,请大家见谅- 主要用到SeekBar控件 先展示效果图吧 点击复制的Button,弹出提示信 ...

  2. Android编写一个视频监控App

    Android编写一个视频监控App 很久没写app了,小项目需要写一个rtmp拉流的视频监控app,简单记录一下. 参考:Android实现rtmp推拉流摄像头(三)_空空7的博客-CSDN博客_a ...

  3. 初识Android 制作一个简单的记账本

    初识Android 制作一个简单的记账本 主要功能 实现一个记账本页面 可以添加数据并更新到页面中 主要步骤 运行截图 主页面 点击红色按钮弹出添加页面 完成后自动更新到目录下 主要功能 实现一个记账 ...

  4. 如何使用CSS简单的制作一个视频网站

    如何使用CSS简单的制作一个视频网站 1.主页的设置 <!DOCTYPE html><html lang="en"><head> <met ...

  5. android边直播边录制视频软件,实现Android本机 视频录制播放 边录边放

    [实例简介]实现Android本机 视频录制播放 边录边放 [实例截图] [核心代码] package yzriver.avc.avccodec; import android.app.Activit ...

  6. 使用小程序制作一个音乐播放器

    此文主要通过小程序制作一个音乐播放器,实现轮播.搜索.播放.快进.暂停.上一曲.下一曲等功能. 一.创建小程序 二.设计页面 三.接口渲染 一.创建小程序 访问微信公众平台,点击账号注册. 选择小程序 ...

  7. C#制作一个图片查看器,具有滚轮放大缩小,鼠标拖动,图像像素化,显示颜色RGB信息功能

    目录 前言 一.界面设计 二.关键技术 1.把图片拖入到窗体并显示 2.实现图像缩放的功能 3.实现图像的移动效果 4.实时显示当前鼠标处的RGB值 5. 右击功能的实现 6.效果展示 总结 前言 使 ...

  8. android做一个音乐播放器,制作一个简单的Android版的音乐播放器

    音乐播放器是一个非常常见的应用,这篇博客就是介绍如何制作一个简单的音乐播放器,这款音乐播放器具有以下的功能:播放歌曲.暂停播放歌曲..显示歌曲的总时长.显示歌曲的当前播放时长.调节滑块可以将歌曲调节到 ...

  9. Android 微信小视频录制功能实现

    目录 开发之前 开发环境 相关知识点 开始开发 案例预览 案例分析 搭建布局 视频预览的实现 自定义双向缩减的进度条 录制事件的处理 长按录制 抬起保存 上滑取消 双击放大(变焦) 实现视频的录制 实 ...

最新文章

  1. 【238】◀▶IEW-Unit03
  2. 云端计算模型的MATLAB仿真与分析
  3. 关于 app测试工具
  4. JS 限制input框的输入字数,并提示可输入字数
  5. 真格量化-主力跟买策略
  6. 软件工程 案例分析作业
  7. 【C语言】C语言结构解析
  8. 异步加载AsyncTask小谈+实例
  9. 博科:物理与虚拟网络的统一管理
  10. win7_64位安装AutoCAD2008详解_完美解决特性面板等局部英文的问题
  11. 【通俗理解】显著性检验,T-test,P-value
  12. Wireshark不同报文颜色的含义
  13. 东南大学计算机学院保研比例,江苏省高校保研率排行榜,南京大学第1,保研率超过1/3...
  14. rails kaminari bootstrap-kaminari-views
  15. docker MySQL 双主_DockerMysql数据库实现双主同步配置详细·TesterHome
  16. threejs 特效,自定义发光墙体,贴图动画版本。发光围栏。
  17. 1196: 最后的胜利者
  18. ios测试硬盘速度软件,MAC测试“读写速度达100MB/s_希捷 Backup Plus Slim for Mac 500GB_移动存储评测-中关村在线...
  19. 如何用计算机记英语词汇,计算机常用英语词汇大全
  20. pancakeswap 前端源码编译及部署-linux

热门文章

  1. 基于Springboot外卖系统17: 新增套餐模块+餐品信息回显+多数据表存储
  2. 软件测试5年外包的感想,最后被领导直接逼退。
  3. 什么是爱?——斯特凡·布罗德本特:互联网怎样使人们变得亲密
  4. Matlab 图像去雾
  5. curl报错 curl: option --form: is badly used here
  6. 二叉树遍历的递归算法
  7. android 平台 c 程序编译
  8. 中国医疗器械行业发展趋势及十四五需求预测报告2021-2027年版
  9. Matlab中save实现保存数据到mat文件的正确使用
  10. web开发 简单的css1 样式和选择器