通过系统API实现录制的几种方案与简单的使用
前言
关于如何使用视频录制,之前也讲到过可以有多种方式实现,Intent 跳转系统页面,FFmpeg之类的软编,以及 CameraX 封装的硬编码实现,MediaRecorder 的配置硬编实现,也可以通过 MediaCodec + MediaMuxer 自行实现硬编。
由于讲到了三种 Camera 的使用预览以及简单的封装。那么本文就简单回顾一下后面几种硬编方案,都是 Android 系统 API 以及对其的封装 API。
文本并不涉及到太专业的音视频知识点。我们只需要了解基本的录制视频需要的一些配置信息就能完成录制(毕竟系统的API已经封装的很完善了)。
- 帧率(Frame Rate): 帧率指的是每秒显示的图像数量,通常以 FPS 为单位,。帧率越高,视频中的动作就会显得更加流畅。常见的帧率有 24、30、60 等。
- 分辨率(Resolution):分辨率是指视频的像素尺寸,通常由宽度和高度表示,如 1920x1080 或 1280x720。较高的分辨率可以提供更清晰的画面。
- 比特率(Bit Rate):比特率表示每秒传输的比特数,通常以 Mbps 为单位。比特率决定了视频的数据量,也影响了视频的质量和文件大小,一般我们都设置为分辨率的宽高乘以3或宽高乘以5。也可以设置一个比较大的值,例如3500000。
- 关键帧(I帧): 一般视频分为关键帧(I帧),预测帧(P帧)和双向预测帧(B帧),我们当前只需要了解I帧是具有高质量和完整的图像信息,它们通常被用作关键帧。我们一般选择封面或缩略图都是从I帧这个独立画面帧中选择。在录制中我们通常需要选择录制视频的I帧间隔,一般都是选1(每一帧都成为关键帧,文件更大)或2(两个I帧之间会存在一些P帧或B帧,文件会更小)
简单了解这些之后我们就可以开始录制了,那么我们以哪一种 Camera 为例来实现硬编录制呢?
其实每一种 Camera 都有各自的优缺点,回调的数据不同,Camera1 回调的是 NV21 ,Camera2 与 Camerax 回调的是 YUV420 , 我们可以通过一些工具类转换对应的格式,实现 Mediacodec 的编码 ,如果只是想简单的实现录制那么使用 CameraX 的 VideoCapture 用例可快速完成预览与录制功能。
用起来比较复杂的 Camera2 虽然代码实现较多,不同的设备兼容性和支持度也各有不同,但它可以实现一些定制化需求。比例感光度,曝光,自动对焦,白平衡,多摄像头支持等等。
本文所用到的录制 API 的实现,也是基于 Camera2 及其封装实现的。
下面具体讲一下不同的方式都是如何实现的。
一、MediaRecorder 录制
本身使用 Intent 是很方便了,但是有些功能有兼容性问题,系统版本与设备支持的程度也不同,导致并没有那么好用,除非啥都不限制。
所以早前的开发我们都是根据系统给我们封装的 MediaRecorder 来录制,通过配置选项就可以完成音频与视频的录制,可以说是非常的方便。
public void startCameraRecord() {
mMediaRecorder = new MediaRecorder();
mMediaRecorder.reset();
if (mCamera != null) {
mMediaRecorder.setCamera(mCamera);
}
mMediaRecorder.setOnErrorListener(new MediaRecorder.OnErrorListener() {
@Override
public void onError(MediaRecorder mr, int what, int extra) {
if (mr != null) {
mr.reset();
}
}
});
mMediaRecorder.setPreviewDisplay(mSurfaceHolder.getSurface());
mMediaRecorder.setVideoSource(MediaRecorder.VideoSource.CAMERA); // 视频源
mMediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC); // 音频源
mMediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); // 视频封装格式
mMediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB); // 音频格式
if (mBestPreviewSize != null) {
// mMediaRecorder.setVideoSize(mBestPreviewSize.width, mBestPreviewSize.height); // 设置分辨率
mMediaRecorder.setVideoSize(640, 480); // 设置分辨率
}
// mMediaRecorder.setVideoFrameRate(16); // 比特率
mMediaRecorder.setVideoEncodingBitRate(1024 * 512);// 设置帧频率,
mMediaRecorder.setOrientationHint(90);// 输出旋转90度,保持竖屏录制
mMediaRecorder.setVideoEncoder(MediaRecorder.VideoEncoder.MPEG_4_SP);// 视频输出格式
mMediaRecorder.setOutputFile(mVecordFile.getAbsolutePath());
try {
mMediaRecorder.prepare();
mMediaRecorder.start();
} catch (IOException e) {
e.printStackTrace();
}
}
它是直接把相机的页面展示出来,不能对特效进行录制,无法指定编码源,只能是纯相机画面,并且对比特率分辨率的支持不友好,需要适配设备支持的分辨率等。
最让人难以接受的是很多机型启动 MediaRecorder 的有 ‘滴’ 的一声提示音,这个系统提示音真的是让人头秃。
难怪后面推出的 CameraX 的录制不用自身的 MediaRecorder ,并且 MediaRecorder 自身也是基于 MediaCodec 实现的,对其进行的封装,所以我们可以看看更底层的 MediaCodec 如何实现。
二、MediaCodec + Camera 异步实现视频编码
如果只是单独的视频画面录制,我们不需要考虑音视频同步的问题,不需要处理时间戳,我们其实可以用 MediaCodec 异步回调的方式更简单的实现。
例如我们可以把原始相机的 I420 格式直接编码为 H264 文件格式。
以 Camera2 为例,我们之前的封装中我们设置了回调,这里就不重复写封装代码,有兴趣可以去前文查看,直接贴出使用的代码了。
//子线程中使用同步队列保存数据
private val originVideoDataList = LinkedBlockingQueue<ByteArray>()
//标记当前是否正在录制中
private var isRecording: Boolean = false
private lateinit var file: File
private lateinit var outputStream: FileOutputStream
fun setupCamera(activity: Activity, container: ViewGroup) {
file = File(CommUtils.getContext().externalCacheDir, "${System.currentTimeMillis()}-record.h264")
if (!file.exists()) {
file.createNewFile()
}
if (!file.isDirectory) {
outputStream = FileOutputStream(file, true)
}
val textureView = AspectTextureView(activity)
textureView.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
mCamera2Provider = Camera2ImageReaderProvider(activity)
mCamera2Provider?.initTexture(textureView)
mCamera2Provider?.setCameraInfoListener(object :
BaseCommonCameraProvider.OnCameraInfoListener {
override fun getBestSize(outputSizes: Size?) {
mPreviewSize = outputSizes
}
override fun onFrameCannback(image: Image) {
if (isRecording) {
// 使用C库获取到I420格式,对应 COLOR_FormatYUV420Planar
val yuvFrame = yuvUtils.convertToI420(image)
// 与MediaFormat的编码格式宽高对应
val yuvFrameRotate = yuvUtils.rotate(yuvFrame, 90)
// 用于测试RGB图片的回调预览
bitmap = Bitmap.createBitmap(yuvFrameRotate.width, yuvFrameRotate.height, Bitmap.Config.ARGB_8888)
yuvUtils.yuv420ToArgb(yuvFrameRotate, bitmap!!)
mBitmapCallback?.invoke(bitmap)
// 旋转90度之后的I420格式添加到同步队列
val bytesFromImageAsType = yuvFrameRotate.asArray()
originVideoDataList.offer(bytesFromImageAsType)
}
}
override fun initEncode() {
mediaCodecEncodeToH264()
}
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture?, width: Int, height: Int) {
this@VideoH264RecoderUtils.surfaceTexture = surfaceTexture
}
})
container.addView(textureView)
}
当摄像头准备好的时候初始化编码器,在每一帧回调中,我们添加到同步队列,因为编码与预览数据不是同一个线程。然后我们可以使用异步回调的方式设置编码。
/**
* 准备数据编码成H264文件
*/
fun mediaCodecEncodeToH264() {
if (mPreviewSize == null) return
//确定要竖屏的,真实场景需要根据屏幕当前方向来判断,这里简单写死为竖屏
val videoWidth: Int
val videoHeight: Int
if (mPreviewSize!!.width > mPreviewSize!!.height) {
videoWidth = mPreviewSize!!.height
videoHeight = mPreviewSize!!.width
} else {
videoWidth = mPreviewSize!!.width
videoHeight = mPreviewSize!!.height
}
YYLogUtils.w("MediaFormat的编码格式,宽:${videoWidth} 高:${videoHeight}")
//配置MediaFormat信息(指定H264格式)
val videoMediaFormat = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, videoWidth, videoHeight)
//添加编码需要的颜色格式
videoMediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar)
// videoMediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible)
//设置帧率
videoMediaFormat.setInteger(MediaFormat.KEY_FRAME_RATE, 30)
//设置比特率
videoMediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, mPreviewSize!!.width * mPreviewSize!!.height * 5)
//设置每秒关键帧间隔
videoMediaFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1)
//创建编码器
val videoMediaCodec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC)
//这里采取的是异步回调的方式,与dequeueInputBuffer,queueInputBuffer 这样的方式获取数据有区别的
// 一种是异步方式,一种是同步的方式。
videoMediaCodec.setCallback(callback)
videoMediaCodec.configure(videoMediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
videoMediaCodec.start()
}
/**
* 具体的音频编码Codec回调
*/
val callback = object : MediaCodec.Callback() {
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {
}
override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) {
Log.e("error", e.message ?: "Error")
}
/**
* 系统获取到有可用的输出buffer,写入到文件
*/
override fun onOutputBufferAvailable(codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo) {
//获取outputBuffer
val outputBuffer = codec.getOutputBuffer(index)
val byteArray = ByteArray(info.size)
outputBuffer?.get(byteArray)
when (info.flags) {
MediaCodec.BUFFER_FLAG_CODEC_CONFIG -> { //编码配置完成
// 创建一个指定大小为 info.size 的空的 ByteArray 数组,数组内全部元素被初始化为默认值0
configBytes = ByteArray(info.size)
// arraycopy复制数组的方法,
// 5个参数,1.源数组 2.源数组的起始位置 3. 目标数组 4.目标组的起始位置 5. 要复制的元素数量
// 这里就是把配置信息全部写入到configBytes数组,用于后期的编码
System.arraycopy(byteArray, 0, configBytes, 0, info.size)
}
MediaCodec.BUFFER_FLAG_END_OF_STREAM -> { //完成处理
//当全部写完之后就回调出去
endBlock?.invoke(file.absolutePath)
}
MediaCodec.BUFFER_FLAG_KEY_FRAME -> { //包含关键帧(I帧),解码器需要这些帧才能正确解码视频序列
// 创建一个临时变量数组,指定大小为 info.size + 配置信息 的空数组
val keyframe = ByteArray(info.size + configBytes!!.size)
// 先 copy 写入配置信息,全部写完
System.arraycopy(configBytes, 0, keyframe, 0, configBytes!!.size)
// 再 copy 写入具体的帧数据,从配置信息的 end 索引开始写,全部写完
System.arraycopy(byteArray, 0, keyframe, configBytes!!.size, byteArray.size)
//全部写完之后我们就能写入到文件中
outputStream.write(keyframe, 0, keyframe.size)
outputStream.flush()
}
else -> { //其他帧的处理
// 其他的数据不需要写入关键帧的配置信息,直接写入到文件即可
outputStream.write(byteArray)
outputStream.flush()
}
}
//释放
codec.releaseOutputBuffer(index, false)
}
/**
* 当系统有可用的输入buffer,读取同步队列中的数据
*/
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {
val inputBuffer = codec.getInputBuffer(index)
val yuvData = originVideoDataList.poll()
//如果获取到自定义结束符,优先结束掉
if (yuvData != null && yuvData.size == 1 && yuvData[0] == (-333).toByte()) {
isEndTip = true
}
//正常的写入
if (yuvData != null && !isEndTip) {
inputBuffer?.put(yuvData)
codec.queueInputBuffer(
index, 0, yuvData.size,
surfaceTexture!!.timestamp / 1000, //注意这里没有用系统时间,用的是surfaceTexture的时间
0
)
}
//如果没数据,写入空数据,等待执行...
if (yuvData == null && !isEndTip) {
codec.queueInputBuffer(
index, 0, 0,
surfaceTexture!!.timestamp / 1000, //注意这里没有用系统时间,用的是surfaceTexture的时间
0
)
}
if (isEndTip) {
codec.queueInputBuffer(
index, 0, 0,
surfaceTexture!!.timestamp / 1000, //注意这里没有用系统时间,用的是surfaceTexture的时间
MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
}
}
}
callback 对象就是异步回调,每一行代码都做了详细的注释。
开始录制与结束录制的控制:
/**
* 停止录制
*/
fun stopRecord(block: ((path: String) -> Unit)? = null) {
endBlock = block
//写入自定义的结束符
originVideoDataList.offer(byteArrayOf((-333).toByte()))
isRecording = false
}
/**
* 开始录制
*/
fun startRecord() {
isRecording = true
}
录制的效果:
三、MediaCodec + AudioRecord 异步实现音频编码
当我们使用 MediaCodec 完成了 H264 的录制之后,我们以同样的方式可以单独的编译出音频,我们以常见的格式 AAC 为例。
只是之前的视频源是来自 Camera2 的回调,而这里我们的音频源来自 AudioRecord 的录制。
//子线程中使用同步队列保存数据
private var mAudioList: LinkedBlockingDeque<ByteArray>? = LinkedBlockingDeque()
//标记当前是否正在录制中
private var isRecording: Boolean = false
// 输入的时候标记是否是结束标记
private var isEndTip = false
/**
* 初始化音频采集
*/
private fun initAudioRecorder() {
//根据系统提供的方法计算最小缓冲区大小
minBufferSize = AudioRecord.getMinBufferSize(
AudioConfig.SAMPLE_RATE,
AudioConfig.CHANNEL_CONFIG,
AudioConfig.AUDIO_FORMAT
)
//创建音频录制器对象
mAudioRecorder = AudioRecord(
MediaRecorder.AudioSource.MIC,
AudioConfig.SAMPLE_RATE,
AudioConfig.CHANNEL_CONFIG,
AudioConfig.AUDIO_FORMAT,
minBufferSize
)
file = File(CommUtils.getContext().externalCacheDir, "${System.currentTimeMillis()}-record.aac")
if (!file.exists()) {
file.createNewFile()
}
if (!file.isDirectory) {
outputStream = FileOutputStream(file, true)
bufferedOutputStream = BufferedOutputStream(outputStream, 4096)
}
YYLogUtils.w("最终输入的File文件为:" + file.absolutePath)
}
/**
* 启动音频录制
*/
fun startAudioRecord() {
//开启线程启动录音
thread(priority = android.os.Process.THREAD_PRIORITY_URGENT_AUDIO) {
isRecording = true //标记是否在录制中
try {
//判断AudioRecord是否初始化成功
if (AudioRecord.STATE_INITIALIZED == mAudioRecorder.state) {
mAudioRecorder.startRecording() //音频录制器开始启动录制
val outputArray = ByteArray(minBufferSize)
while (isRecording) {
var readCode = mAudioRecorder.read(outputArray, 0, minBufferSize)
//这个readCode还有很多小于0的数字,表示某种错误,
if (readCode > 0) {
val realArray = ByteArray(readCode)
System.arraycopy(outputArray, 0, realArray, 0, readCode)
//将读取的数据保存到同步队列
mAudioList?.offer(realArray)
} else {
Log.d("AudioRecoderUtils", "获取音频原始数据的时候出现了某些错误")
}
}
//自定义一个结束标记符,便于让编码器识别是录制结束
val stopArray = byteArrayOf((-666).toByte(), (-999).toByte())
//把自定义的标记符保存到同步队列
mAudioList?.offer(stopArray)
}
} catch (e: IOException) {
e.printStackTrace()
} finally {
//释放资源
mAudioRecorder.release()
}
}
//测试编码
thread {
mediaCodecEncodeToAAC()
}
}
与视频的编码相同的是,都是在不同的线程进行数据收集与编码,所以还是用同步队列来保存数据,我们在子线程中开启音频录制,同时开启子线程启动异步回调方式的编码。
private fun mediaCodecEncodeToAAC() {
try {
//创建音频MediaFormat
val encodeFormat = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, AudioConfig.SAMPLE_RATE, 1)
//配置比特率
encodeFormat.setInteger(MediaFormat.KEY_BIT_RATE, 96000)
encodeFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
//配置最大输入大小
encodeFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, minBufferSize * 2)
//初始化编码器
mAudioMediaCodec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
//这里采取的是异步回调的方式,与dequeueInputBuffer,queueInputBuffer 这样的方式获取数据有区别的
// 一种是异步方式,一种是同步的方式。
mAudioMediaCodec?.setCallback(callback)
mAudioMediaCodec?.configure(encodeFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
mAudioMediaCodec?.start()
} catch (e: IOException) {
Log.e("mistake", e.message ?: "Error")
} finally {
}
}
/**
* 具体的音频编码Codec回调
*/
val callback = object : MediaCodec.Callback() {
val currentTime = Date().time * 1000 //以微秒为计算单位
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {
}
override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) {
Log.e("error", e.message ?: "Error")
}
/**
* 系统获取到有可用的输出buffer,写入到文件
*/
override fun onOutputBufferAvailable(
codec: MediaCodec,
index: Int,
info: MediaCodec.BufferInfo
) {
//通过bufferinfo获取Buffer的数据,这些数据就是编码后的数据
val outBitsSize = info.size
//为AAC文件添加头部,头部占7字节
//AAC有 ADIF和ADTS两种 ADIF只有一个头部剩下都是音频文件
//ADTS是每一段编码都有一个头部
//outpacketSize是最后头部加上返回数据后的总大小
val outPacketSize = outBitsSize + 7 // 7 is ADTS size
//根据index获取buffer
val outputBuffer = codec.getOutputBuffer(index)
// 防止buffer有offset导致自己从0开始获取,
// 取出数据(但是我实验的offset都为0,可能有些不为0的情况)
outputBuffer?.position(info.offset)
//设置buffer的操作上限位置,不清楚的可以查下ByteBuffer(NIO知识),
//了解limit ,position,clear(),filp()都是啥作用
outputBuffer?.limit(info.offset + outBitsSize)
//创建byte数组保存组合数据
val outData = ByteArray(outPacketSize)
//为数据添加头部,后面会贴出,就是在头部写入7个数据
addADTStoPacket(AudioConfig.SAMPLE_RATE, outData, outPacketSize)
//将buffer的数据存入数组中
outputBuffer?.get(outData, 7, outBitsSize)
outputBuffer?.position(info.offset)
//将数据写到文件
bufferedOutputStream.write(outData)
bufferedOutputStream.flush()
outputBuffer?.clear()
//释放输出buffer,并释放Buffer
codec.releaseOutputBuffer(index, false)
}
/**
* 当系统有可用的输入buffer,读取同步队列中的数据
*/
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {
//根据index获取buffer
val inputBuffer = codec.getInputBuffer(index)
//从同步队列中获取还未编码的原始音频数据
val pop = mAudioList?.poll()
//判断是否到达音频数据的结尾,根据自定义的结束标记符判断
if (pop != null && pop.size >= 2 && (pop[0] == (-666).toByte() && pop[1] == (-999).toByte())) {
//如果是结束标记
isEndTip = true
}
//如果数据不为空,而且不是结束标记,写入buffer,让MediaCodec去编码
if (pop != null && !isEndTip) {
//填入数据
inputBuffer?.clear()
inputBuffer?.limit(pop.size)
inputBuffer?.put(pop, 0, pop.size)
//将buffer还给MediaCodec,这个一定要还
//第四个参数为时间戳,也就是,必须是递增的,系统根据这个计算
//音频总时长和时间间隔
codec.queueInputBuffer(
index,
0,
pop.size,
Date().time * 1000 - currentTime,
0
)
}
// 由于2个线程谁先执行不确定,所以可能编码线程先启动,获取到队列的数据为null
// 而且也不是结尾数据,这个时候也要调用queueInputBuffer,将buffer换回去,写入
// 数据大小就写0
// 如果为null就不调用queueInputBuffer 回调几次后就会导致无可用InputBuffer,
// 从而导致MediaCodec任务结束 只能写个配置文件
if (pop == null && !isEndTip) {
codec.queueInputBuffer(
index,
0,
0,
Date().time * 1000 - currentTime,
0
)
}
//发现结束标志,写入结束标志,
//flag为MediaCodec.BUFFER_FLAG_END_OF_STREAM
//通知编码结束
if (isEndTip) {
codec.queueInputBuffer(
index,
0,
0,
Date().time * 1000 - currentTime,
MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
endBlock?.invoke(file.absolutePath)
}
}
}
同样的处理,只是自定义结束的标志符不同,并且由于是单独录制的音频我们需要添加一个ADTS头才能正常的播放。网上随便抄一个:
private fun addADTStoPacket(sampleRateType: Int, packet: ByteArray, packetLen: Int) {
val profile = 2 // AAC LC
val chanCfg = 1 // CPE
packet[0] = 0xFF.toByte()
packet[1] = 0xF9.toByte()
packet[2] = ((profile - 1 shl 6) + (sampleRateType shl 2) + (chanCfg shr 2)).toByte()
packet[3] = ((chanCfg and 3 shl 6) + (packetLen shr 11)).toByte()
packet[4] = (packetLen and 0x7FF shr 3).toByte()
packet[5] = ((packetLen and 7 shl 5) + 0x1F).toByte()
packet[6] = 0xFC.toByte()
}
此时我们就能录制出音频文件。由于大部分都是固定的代码,只是配置不同,效果都是差不多了,每一行代码尽量都有详细的注释。
录制效果如下:
听不到?这没办法啦,自己去跑代码吧。
四、MediaCodec + MediaMuxer 同步音视频编码并封装格式
只是单独的音频与视频录制,我们可以用回调的方式简单的处理了,那音视频一起录制成 MP4 格式呢?
我懂了,回调一个视频,回调一个音频,然后组合在一起就行了!
道理是这么个道理,但是不是这么实现的,音视频的编码有快慢,所以就会导致画面与音频不同步,如果想要保证音视频的同步,只有通过使用相同的时间戳(timestamp)和呈现时间戳(presentation timestamp),将编码后的音频和视频帧进行关联。
我们一般都是使用同步的方式来编码更为方便,我们以音频流的编码为一个线程,视频流的编码为一个视频,合成器 MediaMuxer 的操作放到一个线程中,然后各种完成各自的工作,最终输出MP4文件。
大致的步骤如下:
- 创建音频和视频的MediaCodec对象,并进行配置。
- 将待编码的音频数据提供给音频的MediaCodec进行编码,并获取编码后的音频帧。
- 将待编码的视频数据提供给视频的MediaCodec进行编码,并获取编码后的视频帧。
- 使用音频和视频帧的时间戳和呈现时间戳来保持它们的对应关系。
- 将编码后的音频帧和视频帧写入到一个共享的输出缓冲区中。
- 使用MediaMuxer将共享的输出缓冲区中的音频和视频数据封装成最终的格式(例如MP4)。
- 完成音视频的编码和封装后,释放资源并完成操作。
视频的编码还是之前的逻辑,从 Camera2 获取到数据源,然后添加给编码器处理。
private fun initVideoFormat() {
//确定要竖屏的,真实场景需要根据屏幕当前方向来判断,这里简单写死为竖屏
val videoWidth: Int
val videoHeight: Int
if (mPreviewSize!!.width > mPreviewSize!!.height) {
videoWidth = mPreviewSize!!.height
videoHeight = mPreviewSize!!.width
} else {
videoWidth = mPreviewSize!!.width
videoHeight = mPreviewSize!!.height
}
YYLogUtils.w("MediaFormat的编码格式,宽:${videoWidth} 高:${videoHeight}")
//配置MediaFormat信息(指定H264格式)
val videoMediaFormat = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, videoWidth, videoHeight)
//添加编码需要的颜色类型
videoMediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar)
//设置帧率
videoMediaFormat.setInteger(MediaFormat.KEY_FRAME_RATE, 30)
//设置比特率
videoMediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, mPreviewSize!!.width * mPreviewSize!!.height * 5)
//设置关键帧I帧间隔
videoMediaFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2)
videoCodec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC)
videoCodec!!.configure(videoMediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
videoCodec!!.start()
}
/**
* 视频流的编码处理线程
*/
inner class VideoEncodeThread : Thread() {
//由于摄像头数据的获取与编译不是在同一个线程,还是需要同步队列保存数据
private val videoData = LinkedBlockingQueue<ByteArray>()
// 用于Camera的回调中添加需要编译的原始数据,这里应该为YNV420
fun addVideoData(byteArray: ByteArray?) {
videoData.offer(byteArray)
}
override fun run() {
super.run()
initVideoFormat()
while (!videoExit) {
// 从同步队列中取出 YNV420格式,直接编码为H264格式
val poll = videoData.poll()
if (poll != null) {
encodeVideo(poll, false)
}
}
//发送编码结束标志
encodeVideo(ByteArray(0), true)
// 当编码完成之后,释放视频编码器
videoCodec?.release()
}
}
//调用Codec硬编音频为AAC格式
// dequeueInputBuffer 获取到索引,queueInputBuffer编码写入
private fun encodeVideo(data: ByteArray, isFinish: Boolean) {
val videoInputBuffers = videoCodec!!.inputBuffers
var videoOutputBuffers = videoCodec!!.outputBuffers
val index = videoCodec!!.dequeueInputBuffer(TIME_OUT_US)
if (index >= 0) {
val byteBuffer = videoInputBuffers[index]
byteBuffer.clear()
byteBuffer.put(data)
if (!isFinish) {
videoCodec!!.queueInputBuffer(index, 0, data.size, System.nanoTime() / 1000, 0)
} else {
videoCodec!!.queueInputBuffer(
index,
0,
0,
System.nanoTime() / 1000,
MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
}
val bufferInfo = MediaCodec.BufferInfo()
Log.i("camera2", "编码video $index 写入buffer ${data.size}")
var dequeueIndex = videoCodec!!.dequeueOutputBuffer(bufferInfo, TIME_OUT_US)
if (dequeueIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
if (MuxThread.videoMediaFormat == null)
MuxThread.videoMediaFormat = videoCodec!!.outputFormat
}
if (dequeueIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
videoOutputBuffers = videoCodec!!.outputBuffers
}
while (dequeueIndex >= 0) {
val outputBuffer = videoOutputBuffers[dequeueIndex]
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
bufferInfo.size = 0
}
if (bufferInfo.size != 0) {
muxerThread?.addVideoData(outputBuffer, bufferInfo)
}
Log.i(
"camera2",
"编码后video $dequeueIndex buffer.size ${bufferInfo.size} buff.position ${outputBuffer.position()}"
)
videoCodec!!.releaseOutputBuffer(dequeueIndex, false)
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
dequeueIndex = videoCodec!!.dequeueOutputBuffer(bufferInfo, TIME_OUT_US)
} else {
break
}
}
}
}
而音频的处理我们可以不需要同步队列处理了,使用同步的编码直接在一个线程中处理即可。
inner class AudioEncodeThread : Thread() {
//由于音频使用同步的方式编译,且在同一个线程内,所以不需要额外使用同步队列来处理数据
// private val audioData = LinkedBlockingQueue<ByteArray>()
override fun run() {
super.run()
prepareAudioRecord()
}
}
/**
* 准备初始化AudioRecord
*/
private fun prepareAudioRecord() {
initAudioFormat()
// 初始化音频录制器
audioRecorder = AudioRecord(
MediaRecorder.AudioSource.MIC, AudioConfig.SAMPLE_RATE,
AudioConfig.CHANNEL_CONFIG, AudioConfig.AUDIO_FORMAT, minSize
)
if (audioRecorder!!.state == AudioRecord.STATE_INITIALIZED) {
audioRecorder?.run {
//启动音频录制器开启录音
startRecording()
//读取音频录制器内的数据
val byteArray = ByteArray(SAMPLES_PER_FRAME)
var read = read(byteArray, 0, SAMPLES_PER_FRAME)
//已经在录制了,并且读取到有效数据
while (read > 0 && isRecording) {
//拿到音频原始数据去编译为音频AAC文件
encodeAudio(byteArray, read, getPTSUs())
//继续读取音频原始数据,循环执行
read = read(byteArray, 0, SAMPLES_PER_FRAME)
}
// 当录制完成之后,释放录音器
audioRecorder?.release()
//发送EOS编码结束信息
encodeAudio(ByteArray(0), 0, getPTSUs())
// 当编码完成之后,释放音频编码器
audioCodec?.release()
}
}
}
//调用Codec硬编音频为AAC格式
// dequeueInputBuffer 获取到索引,queueInputBuffer编码写入
private fun encodeAudio(audioArray: ByteArray?, read: Int, timeStamp: Long) {
val index = audioCodec!!.dequeueInputBuffer(TIME_OUT_US)
val audioInputBuffers = audioCodec!!.inputBuffers
if (index >= 0) {
val byteBuffer = audioInputBuffers[index]
byteBuffer.clear()
byteBuffer.put(audioArray, 0, read)
if (read != 0) {
audioCodec!!.queueInputBuffer(index, 0, read, timeStamp, 0)
} else {
audioCodec!!.queueInputBuffer(
index,
0,
read,
timeStamp,
MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
}
val bufferInfo = MediaCodec.BufferInfo()
Log.i("camera2", "编码audio $index 写入buffer ${audioArray?.size}")
var dequeueIndex = audioCodec!!.dequeueOutputBuffer(bufferInfo, TIME_OUT_US)
if (dequeueIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
if (MuxThread.audioMediaFormat == null) {
MuxThread.audioMediaFormat = audioCodec!!.outputFormat
}
}
var audioOutputBuffers = audioCodec!!.outputBuffers
if (dequeueIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
audioOutputBuffers = audioCodec!!.outputBuffers
}
while (dequeueIndex >= 0) {
val outputBuffer = audioOutputBuffers[dequeueIndex]
Log.i(
"camera2",
"编码后audio $dequeueIndex buffer.size ${bufferInfo.size} buff.position ${outputBuffer.position()}"
)
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
bufferInfo.size = 0
}
if (bufferInfo.size != 0) {
// Log.i("camera2", "音频时间戳 ${bufferInfo.presentationTimeUs / 1000}")
// bufferInfo.presentationTimeUs = getPTSUs()
val byteArray = ByteArray(bufferInfo.size + 7)
outputBuffer.get(byteArray, 7, bufferInfo.size)
addADTStoPacket(0x04, byteArray, bufferInfo.size + 7)
outputBuffer.clear()
val headBuffer = ByteBuffer.allocate(byteArray.size)
headBuffer.put(byteArray)
muxerThread?.addAudioData(outputBuffer, bufferInfo) //直接加入到封装线程了
// prevOutputPTSUs = bufferInfo.presentationTimeUs
}
audioCodec!!.releaseOutputBuffer(dequeueIndex, false)
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
dequeueIndex = audioCodec!!.dequeueOutputBuffer(bufferInfo, TIME_OUT_US)
} else {
break
}
}
}
}
private fun initAudioFormat() {
audioMediaFormat = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, AudioConfig.SAMPLE_RATE, 1)
audioMediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, 96000)
audioMediaFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
audioMediaFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, minSize * 2)
audioCodec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
audioCodec!!.configure(audioMediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
audioCodec!!.start()
}
private fun getPTSUs(): Long {
var result = System.nanoTime() / 1000L
if (result < prevOutputPTSUs)
result += prevOutputPTSUs - result
return result
}
/**
* 添加ADTS头
*/
private fun addADTStoPacket(sampleRateType: Int, packet: ByteArray, packetLen: Int) {
val profile = 2 // AAC LC
val chanCfg = 1 // CPE
packet[0] = 0xFF.toByte()
packet[1] = 0xF9.toByte()
packet[2] = ((profile - 1 shl 6) + (sampleRateType shl 2) + (chanCfg shr 2)).toByte()
packet[3] = ((chanCfg and 3 shl 6) + (packetLen shr 11)).toByte()
packet[4] = (packetLen and 0x7FF shr 3).toByte()
packet[5] = ((packetLen and 7 shl 5) + 0x1F).toByte()
packet[6] = 0xFC.toByte()
}
当我们视频编码完成或者音频编码完成就可以把编码之后的数据添加到 MediaMuxer 的音频和视频数据缓冲中。
class MuxThread(val file: File) : Thread() {
//音频缓冲区
private val audioData = LinkedBlockingQueue<EncodeData>()
//视频缓冲区
private val videoData = LinkedBlockingQueue<EncodeData>()
companion object {
var muxIsReady = false
var videoMediaFormat: MediaFormat? = null
var audioMediaFormat: MediaFormat? = null
var muxExit = false
}
private lateinit var mediaMuxer: MediaMuxer
/**
* 需要先初始化Audio线程与资源,然后添加数据源到封装类中
*/
fun addAudioData(byteBuffer: ByteBuffer, bufferInfo: MediaCodec.BufferInfo) {
audioData.offer(EncodeData(byteBuffer, bufferInfo))
}
/**
* 需要先初始化Video线程与资源,然后添加数据源到封装类中
*/
fun addVideoData(byteBuffer: ByteBuffer, bufferInfo: MediaCodec.BufferInfo) {
videoData.offer(EncodeData(byteBuffer, bufferInfo))
}
private fun initMuxer() {
mediaMuxer = MediaMuxer(file.path, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
videoAddTrack = mediaMuxer.addTrack(videoMediaFormat!!)
audioAddTrack = mediaMuxer.addTrack(audioMediaFormat!!)
mediaMuxer.start()
muxIsReady = true
}
private fun muxerParamtersIsReady() = audioMediaFormat != null && videoMediaFormat != null
override fun run() {
super.run()
//校验音频编码与视频编码不为空
while (!muxerParamtersIsReady()) {
}
initMuxer()
while (!muxExit) {
if (audioAddTrack != -1) {
if (audioData.isNotEmpty()) {
val poll = audioData.poll()
Log.i("camera2", "混合写入audio音频 ${poll.bufferInfo.size} ")
mediaMuxer.writeSampleData(audioAddTrack, poll.buffer, poll.bufferInfo)
}
}
if (videoAddTrack != -1) {
if (videoData.isNotEmpty()) {
val poll = videoData.poll()
Log.i("camera2", "混合写入video视频 ${poll.bufferInfo.size} ")
mediaMuxer.writeSampleData(videoAddTrack, poll.buffer, poll.bufferInfo)
}
}
}
mediaMuxer.stop()
mediaMuxer.release()
Log.i("camera2", "合成器释放")
Log.i("camera2", "未写入audio音频 ${audioData.size}")
Log.i("camera2", "未写入video视频 ${videoData.size}")
}
}
当我们开启录制的时候,启动这些线程,此时就会分别开始编码音频数据与视频数据,当音视频数据编码完成并各自绑定呈现时间戳,最终添加到 MuxThread 中开始合成,合成的 MuxThread 内部持有最终的音视频数据,内部开启遍历并判断是否有数据,开始合成为 MP4 文件,当停止播放的时候设置一个 Flag 变量,停止编码并输出文件。
效果:
【注意】这只是 Demo 级别只是用于演示 API 的使用,不要用于实际项目,内部很多 Bug 与兼容性问题,停止录制的时候是立马停止了比如录制10秒但是实际视频只有8秒,就是因为没有做停止的缓冲处理,并且其中资源释放等等并没有处理。如果想要自己写 MediaCodec + MediaMuxer 可以推荐看下面的 VideoCapture 的源码实现。
五、CameraX 自带视频录制
如果说上面我们自己实现的同步硬编代码有这样或那样的问题,那我想要一个开箱即用的录制视频怎么办,其实 CameraX 的录制就已经够我们用了,如果没有一些特效的需求,我们使用 CameraX 的 VideoCapture 就完全能满足需求了!
如果按之前的用法,回调中自己编码,那么我们就需要定义回调,拿到 Image 对象,然后通过自己写 MediaCodec + MediaMuxer 这样和上面的用法就没区别,一样的可以实现视频录制功能。
ImageAnalysis imageAnalysis = new ImageAnalysis.Builder()
.setTargetAspectRatio(screenAspectRatio)
.setTargetRotation(rotation)
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build();
// 在每一帧上应用颜色矩阵
imageAnalysis.setAnalyzer(Executors.newSingleThreadExecutor(), new MyAnalyzer(mContext));
private class MyAnalyzer implements ImageAnalysis.Analyzer {
private YuvUtils yuvUtils = new YuvUtils();
public MyAnalyzer(Context context) {
}
@Override
public void analyze(@NonNull ImageProxy image) {
// 使用C库获取到I420格式,对应 COLOR_FormatYUV420Planar
YuvFrame yuvFrame = yuvUtils.convertToI420(image.getImage());
// 与MediaFormat的编码格式宽高对应
yuvFrame = yuvUtils.rotate(yuvFrame, 90);
// 旋转90度之后的I420格式添加到同步队列
videoThread.addVideoData(yuvFrame.asArray());
}
}
// 启动 AudioRecord 音频录制以及编码等逻辑
但是这一系列的编码操作,CameraX 已经帮我们写好了录制视频的用例 VideoCapture ,它内部就已经帮我们封装了 MediaCodec + MediaMuxer 的逻辑,我们需要使用起来很简单:
//录制视频对象
mVideoCapture = VideoCapture.Builder()
.setTargetAspectRatio(screenAspectRatio)
.setAudioRecordSource(MediaRecorder.AudioSource.MIC) //设置音频源麦克风
//视频帧率
.setVideoFrameRate(30)
//bit率
.setBitRate(3 * 1024 * 1024)
.build()
// 开始录制
fun startCameraRecord(outFile: File) {
mVideoCapture ?: return
val outputFileOptions: VideoCapture.OutputFileOptions = VideoCapture.OutputFileOptions.Builder(outFile).build()
mVideoCapture!!.startRecording(outputFileOptions, mExecutorService, object : VideoCapture.OnVideoSavedCallback {
override fun onVideoSaved(outputFileResults: VideoCapture.OutputFileResults) {
YYLogUtils.w("视频保存成功,outputFileResults:" + outputFileResults.savedUri)
mCameraCallback?.takeSuccess()
}
override fun onError(videoCaptureError: Int, message: String, cause: Throwable?) {
YYLogUtils.e(message)
}
})
}
当我们配置完成最后就能使用这个用例开始录制视频,它内部实现和我们的之前的方式不同,没有通过 I420 或 NV21 等格式编码,而是通过 Surface 直接编码,关键代码如下:
...
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, CodecCapabilities.COLOR_FormatSurface);
// 绑定到 Surface
Surface cameraSurface = mVideoEncoder.createInputSurface();
mCameraSurface = cameraSurface;
SessionConfig.Builder sessionConfigBuilder = SessionConfig.Builder.createFrom(config);
if (mDeferrableSurface != null) {
mDeferrableSurface.close();
}
mDeferrableSurface = new ImmediateSurface(mCameraSurface);
mDeferrableSurface.getTerminationFuture().addListener(
cameraSurface::release, CameraXExecutors.mainThreadExecutor()
);
sessionConfigBuilder.addSurface(mDeferrableSurface);
在之前创建了一个输入Surface对象,并将其与视频编码器建立了关联。使用sessionConfigBuilder.addSurface()方法将 mDeferrableSurface 添加到会话配置中,以确保视频编码器使用这个 Surface 进行数据输入。这样,相机数据就能够通过该Surface输入到视频编码器中进行编码处理了。
源码在 androidx.camera.core.VideoCapture ,谷歌写的是很完善了,我个人也比较喜欢这种方式,使用 COLOR_FormatSurface 的方式兼容性与健壮性更好。
总结
本文大致上讲了一些 Android 本身提供的一些硬编码方式,本质都是 MediaCodec 和一些基于它封装的一些工具。包括 MediaRecorder 与 VideoCapture 都是基于它实现的。
本文的代码有一点多,我们可以通过代码简单的了解 I420、NV21、Surface 几种数据源的不同编码方式。MediaCodec 不同的配置代表什么会有什么样的影响。我们也能了解几种编码方式的不同用法,异步回调与同步处理有什么区别,封装合成器如何使用?
对比几种数据源的编码方式,我个人比较喜欢 Surface 的方式(个人偏好),兼容性与后期的扩展性都会更好,包括后期我们可以实现特效的预览与录制,可以直出录制的特效视频,都会更为方便。
需要注意的是,关于不同的 MediaCodec 的实现,本文中我们使用了不同的方式,但是都是一些 API 的应用方式,没时间完善,它的健壮性也并不好,大家可以用于参考学习,并不推荐大家直接拿过去使用。真正推荐使用的反而是系统的 VideoCapture ,一些简单的录制效果用它完全够用了。(如果想要直接拿过去用的可以看看后面的文章)
既然 Camerax 中的 VideoCapture 这么好,那么可以通过它的录制视频方式在 Camera1 或 Camera2 上实现呢?
最后
如果想要成为架构师或想突破20~30K薪资范畴,那就不要局限在编码,业务,要会选型、扩展,提升编程思维。此外,良好的职业规划也很重要,学习的习惯很重要,但是最重要的还是要能持之以恒,任何不能坚持落实的计划都是空谈。
如果你没有方向,这里给大家分享一套由阿里高级架构师编写的《Android八大模块进阶笔记》,帮大家将杂乱、零散、碎片化的知识进行体系化的整理,让大家系统而高效地掌握Android开发的各个知识点。
相对于我们平时看的碎片化内容,这份笔记的知识点更系统化,更容易理解和记忆,是严格按照知识体系编排的。
全套视频资料:
一、面试合集
二、源码解析合集
三、开源框架合集
欢迎大家一键三连支持,若需要文中资料,直接点击文末CSDN官方认证微信卡片免费领取↓↓↓