软编实现音视频录制
前言
之前的硬编我们可以基于 Android 官方提供的 MediaCodec 来实现编码,基本上可以做到一边录制一边编码并封装为MP4文件。
速度肯定是更快的,而软编则一般是一边录制一边编码,当录制完成之后如果CPU性能没有那么好,可能就会出现等待编码完成。但是软编也有很方便的功能,例如可以很方便的实现添加音轨或裁剪视频之类的功能。
相对而言,软编的兼容性要比硬编更好。并且目前 FFmpeg 等一些流行的软编框架也开始支持硬编。所以现在一些大厂用的比较多的还是 FFmpeg 之类的软编框架。
那么如何使用软编呢?可能之前没接触过的觉得比较麻烦,需要我们在之前硬编的基础上再了解NDK与JNI的一些知识。还需要查询和了解一些对应框架的一些C库的API文档,就能实现对应的功能啦。
下面就给出几个示例,从易到难的实现一些编码的流程。
一、OpenH264的视频编码
Android 平台能实现视频编码的框架有很多,例如FFmpeg,libx264,libvpx,OpenCV,OpenH264 等等。
大家遇到比较多的可能就是 FFmpeg,libx264, 这里就整点不一样的,使用 OpenH264 实现视频流的编码。
对于 OpenH264 的介绍:
OpenH264 是一个开源的 H.264 视频编码器/解码器库,由 Cisco 开发并开源。它是 H.264 视频编解码的实现之一,广泛用于视频通信和流媒体应用。
OpenH264 提供了一组 C/C++ 接口,可以在 Android 平台上使用。通过 OpenH264 库,你可以进行 H.264 视频的编码和解码操作,支持各种分辨率和比特率设置,以及其他与视频编解码相关的参数配置。
市面上用 OpenH264 的项目,基本上应用的场景视频通信与流媒体比较多,例如自己手撕实现一个 WebRTC 青春版?
对于如何集成这种开源的C库,我们常见的做法是下载源码到 Linux 机器上,使用NDK编译为静态库或动态库,然后把静态库或动态库复制到 AS 中,然后使用 CMake 工具绑定源码与头文件,自己实现 JNI 调用库中的 API 完成功能,最终打包的APK会编译出2个so文件。
另一种常见的方式是直接把项目的源码与头文件全部都导入到AS,直接使用 CMake 编译出动态库/静态库。
由于涉及到部分 NDK 和 部分 JNI 的知识点,网上关于 NDK(CMale)/JNI 的学习资料也太多了,并且也不是本文的重点,这里就不过多赘述了,加上注释了尽量跳过。
文件目录大致如下:
当然so文件放入jniLibs也是可以的,只是需要加
css
sourceSets {
main {
jniLibs.srcDirs = ['libs']
}
}
并且在 CMake 的链接源码的路径位置不同,其实差别不大。
集成完毕之后我们把调用C库API的类定义一下:
scss
#include "VideoEncoder.h"
VideoEncoder::VideoEncoder(JNIEnv *jniEnv, jstring path) {
Init(jniEnv, path);
}
void VideoEncoder::Encode(uint8_t *yuv_data, int width, int height) {
if (yuv_data == NULL || *yuv_data == 0 || width == 0 || height == 0) {
LOGE(TAG, "data err!")
return;
}
LOGI(TAG, "Encode data .....yuv_data width=%d,height=%d", width, height)
sPicture.iColorFormat = videoFormatI420;
sPicture.iPicWidth = width;
sPicture.iPicHeight = height;
sPicture.iStride[0] = sPicture.iPicWidth;
sPicture.iStride[1] = sPicture.iStride[2] = sPicture.iPicWidth / 2;
sPicture.uiTimeStamp = timestamp_++;
sPicture.pData[0] = yuv_data;
sPicture.pData[1] = yuv_data + width * height;
sPicture.pData[2] = yuv_data + width * height * 5 / 4;
int err = ppEncoder->EncodeFrame(&sPicture, &encoded_frame_info);
if (err) {
LOGE(TAG, "Encode err err=%d", err);
return;
}
unsigned char *outBuf = encoded_frame_info.sLayerInfo[0].pBsBuf;
LOGI(TAG, "frame type:%d", encoded_frame_info.sLayerInfo[0].eFrameType)
if (out264.is_open()) {
out264.write(reinterpret_cast<const char *>(outBuf), encoded_frame_info.iFrameSizeInBytes);
}
}
void VideoEncoder::Init(JNIEnv *jniEnv, jstring path) {
LOGI(TAG, "VideoDecoder Init start")
jobject m_path_ref = jniEnv->NewGlobalRef(path);
m_path = jniEnv->GetStringUTFChars(path, NULL);
jniEnv->GetJavaVM(&m_jvm_for_thread);
int result = WelsCreateSVCEncoder(&ppEncoder);
if (result != cmResultSuccess) {
LOGE(TAG, "WelsCreateSVCEncoder fail ! result=%d", result)
return;
}
//获取默认参数
ppEncoder->GetDefaultParams(¶mExt);
ECOMPLEXITY_MODE complexityMode = HIGH_COMPLEXITY;
RC_MODES rc_mode = RC_BITRATE_MODE;
bool bEnableAdaptiveQuant = false;
//TODO 这里暂时写死,需要外面对应预览分辨率也设置成一样
paramExt.iUsageType = CAMERA_VIDEO_REAL_TIME;
paramExt.iPicWidth = 720;
paramExt.iPicHeight = 1280;
paramExt.iTargetBitrate = 720 * 1280 * 4;
paramExt.iMaxBitrate = 720 * 1280 * 5;
paramExt.iRCMode = rc_mode;
paramExt.fMaxFrameRate = 30;
paramExt.iTemporalLayerNum = 1;
paramExt.iSpatialLayerNum = 1;
paramExt.bEnableDenoise = false;
paramExt.bEnableBackgroundDetection = true;
paramExt.bEnableAdaptiveQuant = false;
paramExt.bEnableFrameSkip = false;
paramExt.bEnableLongTermReference = false;
paramExt.bEnableAdaptiveQuant = bEnableAdaptiveQuant;
paramExt.bEnableSSEI = true;
paramExt.bEnableSceneChangeDetect = true;
paramExt.uiIntraPeriod = 15u;
paramExt.eSpsPpsIdStrategy = CONSTANT_ID;
paramExt.bPrefixNalAddingCtrl = false;
paramExt.iComplexityMode = complexityMode;
paramExt.bEnableFrameSkip = false;
paramExt.sSpatialLayers[0].iVideoWidth = paramExt.iPicWidth;
paramExt.sSpatialLayers[0].iVideoHeight = paramExt.iPicHeight;
paramExt.sSpatialLayers[0].fFrameRate = paramExt.fMaxFrameRate;
paramExt.sSpatialLayers[0].iSpatialBitrate = paramExt.iTargetBitrate;
paramExt.sSpatialLayers[0].iMaxSpatialBitrate = paramExt.iMaxBitrate;
ppEncoder->InitializeExt(¶mExt);
out264.open(m_path, std::ios::ate | std::ios::binary);
LOGI(TAG, "VideoDecoder Init end")
}
VideoEncoder::~VideoEncoder() {
//TODO
if (ppEncoder) {
ppEncoder->Uninitialize();
WelsDestroySVCEncoder(ppEncoder);
}
}
再使用自己的JNI去调用这个封装:
scss
extern "C"
JNIEXPORT jlong JNICALL
Java_com_newki_openh264util_OpenH264Util_createEncoder(JNIEnv *env, jobject thiz, jint width,
jint height, jstring output_path) {
VideoEncoder *decoder = new VideoEncoder(env, output_path);
return (jlong)decoder;
}
extern "C"
JNIEXPORT jlong JNICALL
Java_com_newki_openh264util_OpenH264Util_encode(JNIEnv *env, jobject thiz, jlong p_encoder,
jbyteArray data, jint width, jint height) {
if (0 != p_encoder) {
VideoEncoder *decoder = (VideoEncoder *)p_encoder;
jbyte *bytes = env->GetByteArrayElements(data, 0);
int arrayLength = env->GetArrayLength(data);
char *chars = new char[arrayLength + 1];
memset(chars, 0x0, arrayLength + 1);
memcpy(chars, bytes, arrayLength);
chars[arrayLength] = 0;
env->ReleaseByteArrayElements(data, bytes, 0);
decoder->Encode(reinterpret_cast<uint8_t *>(chars), width, height);
}
return 0;
}
然后把自己定义的JNI方法,链接到 Java/Kotlin 工具类上。
kotlin
class OpenH264Util {
init {
try {
System.loadLibrary("openh264util")
} catch (e: Exception) {
Log.e("OpenH264Util", "Couldn't load openh264util library: $e")
}
}
external fun createEncoder(width: Int, height: Int, outputPath: String): Long
external fun encode(pEncoder: Long, data: ByteArray?, width: Int, height: Int): Long
}
最后我们就需要配置 CMakeLists.txt 配置 OpenH264 的源码与头文件,还需要配置 JNI 生成动态库的配置等等。
常见的几个 CMakeLists.txt 命令,
-
find_library:该命令用于在系统中查找指定的库文件。它会搜索标准路径和自定义路径,以便找到需要链接的库文件。
-
target_link_libraries:该命令用于将目标与一个或多个库文件进行链接。它将目标与指定的库文件进行关联,以便在构建时能够正确链接所需的依赖项。
-
add_library:该命令用于向项目添加一个库。它可以创建静态库(.a 文件)、动态库(.so 文件)或可执行文件等不同类型的库。
-
set_target_properties:该命令用于设置目标(库或可执行文件)的属性。可以通过该命令指定编译选项、链接选项、输出目录等属性。
-
include_directories:该命令用于指定头文件的搜索路径。可以通过该命令将特定目录添加到头文件搜索路径中,使源代码能够正确找到所需的头文件。
1 和 2 配合使用,3 和 4 配合使用,5 是引入头文件用的。
以我们的 OpenH264 的 CMake 配置文件为例:
bash
cmake_minimum_required(VERSION 3.4.1)
#配置动态链接库所在的目录(简单方式)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -L${CMAKE_SOURCE_DIR}../../../../libs/${ANDROID_ABI}")
#配置动态链接库对应头文件的目录
include_directories(${PROJECT_SOURCE_DIR}/openh264/include)
#配置我们自己的JNI以什么名字什么方式打包为动态库/静态库。以及链接对应的源码路径
add_library(
openh264util
SHARED
openh264util.cpp
VideoEncoder.cpp
utils/logger.h
utils/timer.c)
#这两个配合使用,都是一些固定写法
find_library(
log-lib
log)
target_link_libraries(
openh264util
openh264
${log-lib}
jnigraphics
android)
使用:
kotlin
h264File = File(CommUtils.getContext().externalCacheDir, "${System.currentTimeMillis()}-record.h264")
if (!h264File.exists()) {
h264File.createNewFile()
}
if (!h264File.isDirectory) {
h264OutputStream = FileOutputStream(h264File, 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)
// 旋转90度之后的I420格式添加到同步队列
val bytesFromImageAsType = yuvFrameRotate.asArray()
// 使用OpenH264编译视频文件
openH264Util?.encode(
pEncoder!!,
bytesFromImageAsType,
image.height,
image.width,
)
}
}
override fun initEncode() {
mediaCodecEncodeToH264()
}
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture?, width: Int, height: Int) {
this@VideoSoftRecoderUtils.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}")
openH264Util = OpenH264Util()
pEncoder = openH264Util?.createEncoder(videoWidth, videoHeight, h264File.absolutePath)
}
...
效果:
二、fdk-aac的音频编码
同样的音频录制的话也有很多的方案,我们这里以比较流行的 fdk-aac 来编码音频。
这里特意用不同的方式来集成:
在 jniLibs 中加入了动态库之后,我们如此配置 CMake:
scss
cmake_minimum_required(VERSION 3.4.1)
#配置动态链接库对应头文件的目录
include_directories(${PROJECT_SOURCE_DIR}/include)
# 添加我们自己的JNI文件编辑出来的库
add_library(
fdkcodec
SHARED
fdkcodec.cpp)
# 之前我们以简单的方式集成第三方C库 set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -L${CMAKE_SOURCE_DIR}../../../../libs/${ANDROID_ABI}")
# 现在我们用复杂的方式集成第三方库 add_library + set_target_properties 套餐
add_library(fdk-aac
SHARED
IMPORTED)
set_target_properties(
fdk-aac
PROPERTIES IMPORTED_LOCATION
${PROJECT_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})
#必不可少的老搭档了
find_library(
log-lib
log)
target_link_libraries(
fdkcodec
fdk-aac
${log-lib})
大致上就是这两种方式使用,都是一些套路代码,固定的一些模板配置。
使用:
kotlin
//音频
private lateinit var codec: FDKAACUtil
private lateinit var mAudioRecorder: AudioRecord
private val minBufferSize = AudioRecord.getMinBufferSize(
AudioConfig.SAMPLE_RATE, AudioConfig.CHANNEL_CONFIG,
AudioConfig.AUDIO_FORMAT
)
private lateinit var readBuffer: ShortArray
private lateinit var audioFile: File
private lateinit var audioBufferedOutputStream: BufferedOutputStream
private lateinit var audioOutputStream: FileOutputStream
var bufferSize = 4096
fun setup() {
audioFile = File(CommUtils.getContext().externalCacheDir, "${System.currentTimeMillis()}-record.aac")
if (!audioFile.exists()) {
audioFile.createNewFile()
}
if (!audioFile.isDirectory) {
audioOutputStream = FileOutputStream(audioFile, true)
audioBufferedOutputStream = BufferedOutputStream(audioOutputStream, 4096)
}
//创建音频录制器对象
mAudioRecorder = AudioRecord(
MediaRecorder.AudioSource.MIC,
AudioConfig.SAMPLE_RATE,
AudioConfig.CHANNEL_CONFIG,
AudioConfig.AUDIO_FORMAT,
minBufferSize
)
codec = FDKAACUtil()
codec.init(AudioConfig.SAMPLE_RATE, 2, AudioConfig.SAMPLE_RATE * 2 * 3 / 2)
codec.initDecoder()
}
/**
* 启动音频录制
*/
fun startAudioRecord() {
//开启线程启动录音
thread(priority = android.os.Process.THREAD_PRIORITY_URGENT_AUDIO) {
isRecording = true
var encodedData: ByteArray?
var decodedData: ByteArray?
if (minBufferSize > bufferSize) {
bufferSize = minBufferSize
}
readBuffer = ShortArray(bufferSize shr 1)
try {
if (AudioRecord.STATE_INITIALIZED == mAudioRecorder.state) {
mAudioRecorder.startRecording() //音频录制器开始启动录制
while (isRecording) {
val readCount: Int = mAudioRecorder.read(readBuffer, 0, bufferSize shr 1)
Log.d("TAG", " buffer size: " + (bufferSize shr 1) + " readCount: " + readCount)
encodedData = codec.encode(readBuffer)!!
if (encodedData != null) {
Log.d("TAG", "encodedData1: " + encodedData)
audioBufferedOutputStream.write(encodedData)
audioBufferedOutputStream.flush()
decodedData = codec.decode(encodedData)!!
if (decodedData != null) {
Log.d("TAG", "decodedData2: " + decodedData)
audioBufferedOutputStream.write(encodedData)
audioBufferedOutputStream.flush()
}
} else {
Log.d("TAG", " Encode fail ")
}
}
encodedData = codec.encode(null);
while (encodedData != null) {
Log.d("TAG", "encodedData3: " + encodedData)
audioBufferedOutputStream.write(encodedData)
audioBufferedOutputStream.flush()
encodedData = codec.encode(null);
}
}
} catch (e: IOException) {
e.printStackTrace()
} finally {
//释放资源
mAudioRecorder.stop()
mAudioRecorder.release()
codec.release()
codec.releaseDecoder()
audioBufferedOutputStream.close()
}
}
}
如果想单独播放AAC文件注意添加ADTS头。
有了H264 和 AAC 文件我们就可以合成封装 MP4 文件了,也有很多方式很多框架来操作,但是我们都没有设置呈现时间,这样会导致音视频画面不同步,一般我们是不推荐这种做法。
而通常我们的做法就是使用 FFmpeg 框架来统一管理。业内的常见做法是 FFmpeg + x264 + fdk-aac 。下面就一起来看看,如何使用 FFmpeg 来统筹管理音视频编码与呈现时间实现编码与封装。
三、FFmpeg的音视频编码
首先网上有很多教程可以编译出动态库/静态库,.so或.a的后缀文件都是可以用的。
我们拿到对应版本的 FFmpeg 头文件与编译出来的库文件,就可以集成到 Android 项目中来。
比如我们编译出了指定版本的静态库,那么我们可以选择放入lib文件(需要指定文件)或者放入默认的jniLibs文件夹。上面的示例中有两种方案的Demo。这里以jniLibs为例:
那么我们的 CMakeLists.txt 配置文件大概如下配置:
bash
cmake_minimum_required(VERSION 3.4.1)
# 引入全部的头文件
include_directories(
include
${CMAKE_SOURCE_DIR}/recorder
${CMAKE_SOURCE_DIR}/util
)
# 以简单的方式链接到已编译的动态/静态库
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=gnu++11 -L${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}")
# 简单的链接方式其实可以分开写(上面是合并的写法)
#set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=gnu++11")
#link_directories(${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})
# 自己定义的一些CPP需要打包成SO库,设置变量指定哪些文件需要处理
file(GLOB src-files
${CMAKE_SOURCE_DIR}/*.cpp
${CMAKE_SOURCE_DIR}/util/*.cpp
${CMAKE_SOURCE_DIR}/common/*.cpp
${CMAKE_SOURCE_DIR}/recorder/VideoRecorder.cpp
${CMAKE_SOURCE_DIR}/recorder/AudioRecorder.cpp
${CMAKE_SOURCE_DIR}/recorder/MediaRecorder.cpp
)
# 自己定义的一些JNI打包为SO动态库
add_library(
ffmpeg-record
SHARED
${src-files}
)
# 固定套路,不多说了
find_library(
log-lib
log)
target_link_libraries(
ffmpeg-record
# 用到第三方C库模块
avformat
avcodec
avfilter
swresample
swscale
avutil
fdk-aac
x264
${log-lib}
)
3.1 音频编码
先看看音频如何编码,其实与 Android MediaCodec 的方式有点类似。启动录制的时候初始化一些资源:
scss
int AudioRecorder::StartRecord() {
int result = -1;
do {
// 用于创建并初始化一个输出容器上下文,并分配相关的内存空间。
result = avformat_alloc_output_context2(&m_pFormatCtx, nullptr, nullptr, m_outUrl);
if (result < 0) {
LOGCATE("SingleAudioRecorder::StartRecord avformat_alloc_output_context2 ret=%d", result);
break;
}
//设置写入到指定本地文件
result = avio_open(&m_pFormatCtx->pb, m_outUrl, AVIO_FLAG_READ_WRITE);
if (result < 0) {
LOGCATE("SingleAudioRecorder::StartRecord avio_open ret=%d", result);
break;
}
//可以创建一个新的音视频流,并将其与指定的编码器关联起来
//下面还需要进一步设置该音视频流的相关参数,例如编码参数、帧率、分辨率等,以确保音视频数据能够正确地写入到文件或输出
m_pStream = avformat_new_stream(m_pFormatCtx, nullptr);
if (m_pStream == nullptr) {
result = -1;
break;
}
m_pCodecCtx = m_pStream->codec;
m_pCodecCtx->strict_std_compliance = FF_COMPLIANCE_EXPERIMENTAL;
m_pCodecCtx->codec_id = AV_CODEC_ID_AAC;
m_pCodecCtx->codec_type = AVMEDIA_TYPE_AUDIO;
m_pCodecCtx->sample_fmt = AV_SAMPLE_FMT_FLTP;
m_pCodecCtx->sample_rate = DEFAULT_SAMPLE_RATE;
m_pCodecCtx->channel_layout = DEFAULT_CHANNEL_LAYOUT;
m_pCodecCtx->channels = av_get_channel_layout_nb_channels(m_pCodecCtx->channel_layout);
m_pCodecCtx->bit_rate = 96000;
AVOutputFormat *avOutputFormat = m_pFormatCtx->oformat;
//遍历 FFmpeg 支持的所有编码器,直到找到与指定标识符匹配的编码器为止
m_pCodec = avcodec_find_encoder(avOutputFormat->audio_codec);
if (m_pCodec == nullptr) {
result = -1;
break;
}
//打开找到的编码器并进行必要的初始化工作
result = avcodec_open2(m_pCodecCtx, m_pCodec, nullptr);
if (result < 0) {
LOGCATE("SingleAudioRecorder::StartRecord avcodec_open2 ret=%d", result);
break;
}
//用于调试,打印每个流的信息
av_dump_format(m_pFormatCtx, 0, m_outUrl, 1);
//用于创建 AVFrame 结构体以用于存储音视频数据
m_pFrame = av_frame_alloc();
m_pFrame->nb_samples = m_pCodecCtx->frame_size;
m_pFrame->format = m_pCodecCtx->sample_fmt;
//确定缓冲区
m_frameBufferSize = av_samples_get_buffer_size(
nullptr, m_pCodecCtx->channels, m_pCodecCtx->frame_size, m_pCodecCtx->sample_fmt, 1
);
//分配空间
m_pFrameBuffer = (uint8_t *) av_malloc(m_frameBufferSize);
avcodec_fill_audio_frame(m_pFrame, m_pCodecCtx->channels, m_pCodecCtx->sample_fmt,
(const uint8_t *) m_pFrameBuffer, m_frameBufferSize, 1);
//写文件头
avformat_write_header(m_pFormatCtx, nullptr);
av_new_packet(&m_avPacket, m_frameBufferSize);
//音频转码器
m_swrCtx = swr_alloc();
av_opt_set_channel_layout(m_swrCtx, "in_channel_layout", m_channelLayout, 0);
av_opt_set_channel_layout(m_swrCtx, "out_channel_layout", AV_CH_LAYOUT_STEREO, 0);
av_opt_set_int(m_swrCtx, "in_sample_rate", m_sampleRate, 0);
av_opt_set_int(m_swrCtx, "out_sample_rate", DEFAULT_SAMPLE_RATE, 0);
av_opt_set_sample_fmt(m_swrCtx, "in_sample_fmt", AVSampleFormat(m_sampleFormat), 0);
av_opt_set_sample_fmt(m_swrCtx, "out_sample_fmt", AV_SAMPLE_FMT_FLTP, 0);
swr_init(m_swrCtx);
} while (false);
if (result >= 0) {
//开启线程编码
m_encodeThread = new thread(StartAACEncoderThread, this);
}
return 0;
}
编码线程如下:
rust
void AudioRecorder::StartAACEncoderThread(AudioRecorder *recorder) {
while (!recorder->m_exit || !recorder->m_frameQueue.Empty()) {
if (recorder->m_frameQueue.Empty()) {
//队列为空,休眠等待
usleep(10 * 1000);
continue;
}
//从队列中取出数据准备编码
AudioFrame *audioFrame = recorder->m_frameQueue.Pop();
AVFrame *pFrame = recorder->m_pFrame;
//通过 swr_convert 函数进行音频重采样,可以实现不同采样格式之间的兼容和转换
int result = swr_convert(recorder->m_swrCtx, pFrame->data, pFrame->nb_samples, (const uint8_t **) &(audioFrame->data),
audioFrame->dataSize / 4);
if (result >= 0) {
pFrame->pts = recorder->m_frameIndex++;
//线程中开启编码
recorder->EncodeFrame(pFrame);
}
delete audioFrame;
}
}
具体的编码过程与MediaCodec有点类似:
ini
int AudioRecorder::EncodeFrame(AVFrame *pFrame) {
int result = 0;
//这个方法用于将音频帧发送给音频编码器进行编码
// 第一个参数是音频编码器的上下文(AVCodecContext),第二个参数是待编码的音频帧(AVFrame)这个方法会将音频帧送入编码器进行处理,不会立即返回编码的数据
result = avcodec_send_frame(m_pCodecCtx, pFrame);
if (result < 0) {
return result;
}
while (!result) {
//这个方法用于从音频编码器接收编码后的音频数据包
//第一个参数是音频编码器的上下文,第二个参数是用于接收编码数据包的 AVPacket 结构体 这个方法会阻塞等待编码器输出数据,直到接收到完整的编码数据包才会返回。
result = avcodec_receive_packet(m_pCodecCtx, &m_avPacket);
if (result == AVERROR(EAGAIN) || result == AVERROR_EOF) {
return 0;
} else if (result < 0) {
return result;
}
m_avPacket.stream_index = m_pStream->index;
av_interleaved_write_frame(m_pFormatCtx, &m_avPacket); //用于将音频或视频数据包写入输出容器
av_packet_unref(&m_avPacket); //释放内存
}
return 0;
}
那么数据哪里来呢?还是和 MediaCodec 一样的套路,通过 AudioRecord 的API 拿到原生数据流之后传递给 FFmpeg 放入到 AudioRecorder 的队列中。
可以从JNI的方法入手:
kotlin
external fun native_OnAudioData(data: ByteArray?, len: Int)
具体的JNI方法实现如下:
scss
extern "C"
JNIEXPORT void JNICALL
Java_com_newki_ffmpeg_1record_FFmpegRecordUtils_native_1OnAudioData(JNIEnv *env, jobject thiz,
jbyteArray data,
jint size) {
int len = env->GetArrayLength(data);
unsigned char *buf = new unsigned char[len];
env->GetByteArrayRegion(data, 0, len, reinterpret_cast<jbyte *>(buf));
MediaRecorder *recoder = MediaRecorder::GetContext(env, thiz);
if (recoder) recoder->OnAudioData(buf, len);
delete[] buf;
}
通过 MediaRecorder 这个中间包装类,把数据流封装为 自定义的 AudioFrame 对象,然后再调用到 AudioRecorder 类中的 OnFrame2Encode 方法进行数据的写入队列。
MediaRecorde 的 OnAudioData 方法,封装参数,调用 AudioRecorder 的 OnFrame2Encode 方法进行写入:
arduino
// 直接赋予数据,去音频编码
void MediaRecorde::OnAudioData(uint8_t *pData, int size) {
AudioFrame audioFrame(pData, size, false);
if(m_pAudioRecorder != nullptr)
m_pAudioRecorder->OnFrame2Encode(&audioFrame);
}
那么整体的流程就能跑通了,是不是和 MediaCodec 的硬编流程很像。
3.2 视频编码
其实视频编码与音频编码的区别不大,只是数据源一个是来自 Camera ,一个来自AudioRecord 的API 。
这里我们以 Camera2 的API 为例子,拿到的数据格式是YUV420(I420),我们以 YUV420 的格式通过 FFmpeg 的方式编码为 H264 格式并直接输出为 MP4 文件。
我们这里直接从 JNI 的方法定义开始:
kotlin
external fun native_OnPreviewFrame(format: Int, data: ByteArray?, width: Int, height: Int)
对应的 JNI 方法为:
arduino
extern "C"
JNIEXPORT void JNICALL
Java_com_newki_ffmpeg_1record_FFmpegRecordUtils_native_1OnPreviewFrame(JNIEnv *env, jobject thiz,
jint format,
jbyteArray data,
jint width,
jint height) {
int len = env->GetArrayLength(data);
unsigned char *buf = new unsigned char[len];
env->GetByteArrayRegion(data, 0, len, reinterpret_cast<jbyte *>(buf));
MediaRecorder *record = MediaRecorder::GetContext(env, thiz);
if (record) record->OnPreviewFrame(format, buf, width, height);
delete[] buf;
}
我们把编码格式,宽高,数据流等信息传递给同样的中间类 MediaRecorder ,之前音频的编码是编码为自定义的 AudioFrame 对象,保存了一些编码信息,同样的我们视频数据也可以写入一个自定义的封装对象 NativeImage 对象,内部包含数据流,宽高,编码信息等。
ini
void MediaRecorder::OnPreviewFrame(int format, uint8_t *pBuffer, int width, int height){
NativeImage nativeImage;
nativeImage.format = format;
nativeImage.width = width;
nativeImage.height = height;
nativeImage.ppPlane[0] = pBuffer;
switch (format){
case IMAGE_FORMAT_NV12:
case IMAGE_FORMAT_NV21:
nativeImage.ppPlane[1] = nativeImage.ppPlane[0] + width * height;
nativeImage.pLineSize[0] = width;
nativeImage.pLineSize[1] = width;
break;
case IMAGE_FORMAT_I420:
nativeImage.ppPlane[1] = nativeImage.ppPlane[0] + width * height;
nativeImage.ppPlane[2] = nativeImage.ppPlane[1] + width * height / 4;
nativeImage.pLineSize[0] = width;
nativeImage.pLineSize[1] = width / 2;
nativeImage.pLineSize[2] = width / 2;
break;
default:
break;
}
std::unique_lock<std::mutex> lock(m_mutex);
if(m_pVideoRecorder!= nullptr) {
m_pVideoRecorder->OnFrame2Encode(&nativeImage);
}
lock.unlock();
}
把数据根据对于的格式封装到自定义对象之后,我们就能通过 VideoRecorder 的 OnFrame2Encode 方法写入到队列中:
ini
//拿到Camera数据源加入数据帧队列,由外部调用
int VideoRecorder::OnFrame2Encode(NativeImage *inputFrame) {
if(m_exit) return 0;
// 由于要封装宽高与编码信息等,这里封装自定义的对象放入队列中
NativeImage *pImage = new NativeImage();
pImage->width = inputFrame->width;
pImage->height = inputFrame->height;
pImage->format = inputFrame->format;
m_frameQueue.Push(pImage);
return 0;
}
那么具体的取数据,编码的流程怎么办?其实和音频编码比较类似,这里也尽量用同样的方法名来走一下流程:
当 VideoRecorder 准备编码的时候,初始化一些信息,具体都标记的注释如下:
ini
int VideoRecorder::StartRecord() {
int result = 0;
do{
// 用于创建并初始化一个输出容器上下文,并分配相关的内存空间。
result = avformat_alloc_output_context2(&m_pFormatCtx, nullptr, nullptr, m_outUrl);
if(result < 0) {
break;
}
//设置写入到指定本地文件
result = avio_open(&m_pFormatCtx->pb, m_outUrl, AVIO_FLAG_READ_WRITE);
if(result < 0) {
break;
}
//可以创建一个新的音视频流,并将其与指定的编码器关联起来
//下面还需要进一步设置该音视频流的相关参数,例如编码参数、帧率、分辨率等,以确保音视频数据能够正确地写入到文件或输出
m_pStream = avformat_new_stream(m_pFormatCtx, nullptr);
if (m_pStream == nullptr) {
result = -1;
break;
}
//遍历 FFmpeg 支持的所有编码器,直到找到与指定标识符匹配的编码器为止
m_pCodec = avcodec_find_encoder(AV_CODEC_ID_MPEG4);
if (m_pCodec == nullptr) {
result = -1;
break;
}
//创建一个 AVCodecContext 对象,并为其分配内存空间。这个对象将用于设置和管理视频编解码器的参数,包括视频的分辨率、帧率、位率、编码格式等。
m_pCodecCtx = avcodec_alloc_context3(m_pCodec);
m_pCodecCtx->codec_id = m_pCodec->id;
m_pCodecCtx->codec_type = AVMEDIA_TYPE_VIDEO;
m_pCodecCtx->pix_fmt = AV_PIX_FMT_YUV420P;
m_pCodecCtx->width = m_frameWidth;
m_pCodecCtx->height = m_frameHeight;
m_pCodecCtx->time_base.num = 1;
m_pCodecCtx->time_base.den = m_frameRate;
m_pCodecCtx->bit_rate = m_bitRate;
m_pCodecCtx->gop_size = 15;
//用于根据给定的 AVCodecContext 来更新 AVCodecParameters 结构体的值
result = avcodec_parameters_from_context(m_pStream->codecpar, m_pCodecCtx);
if(result < 0) {
break;
}
//指定每秒显示的帧数
av_stream_set_r_frame_rate(m_pStream, {1, m_frameRate});
//打开找到的编码器并进行必要的初始化工作
result = avcodec_open2(m_pCodecCtx, m_pCodec, nullptr);
if(result < 0) {
break;
}
//用于调试,打印每个流的信息
av_dump_format(m_pFormatCtx, 0, m_outUrl, 1);
//用于创建 AVFrame 结构体以用于存储音视频数据
m_pFrame = av_frame_alloc();
m_pFrame->width = m_pCodecCtx->width;
m_pFrame->height = m_pCodecCtx->height;
m_pFrame->format = m_pCodecCtx->pix_fmt;
//确定缓冲区
int bufferSize = av_image_get_buffer_size(m_pCodecCtx->pix_fmt, m_pCodecCtx->width,
m_pCodecCtx->height, 1);
//分配空间
m_pFrameBuffer = (uint8_t *) av_malloc(bufferSize);
//用于根据提供的图像数据指针和图像参数填充图像数据数组
av_image_fill_arrays(m_pFrame->data, m_pFrame->linesize, m_pFrameBuffer, m_pCodecCtx->pix_fmt,
m_pCodecCtx->width, m_pCodecCtx->height, 1);
AVDictionary *opt = 0;
if (m_pCodecCtx->codec_id == AV_CODEC_ID_H264) {
av_dict_set_int(&opt, "video_track_timescale", 25, 0);
av_dict_set(&opt, "preset", "slow", 0);
av_dict_set(&opt, "tune", "zerolatency", 0);
}
//写入媒体容器格式的文件头
avformat_write_header(m_pFormatCtx, &opt);
av_new_packet(&m_avPacket, bufferSize * 3);
} while(false);
if(result >=0) {
//全部成功之后启动编码线程,在线程中尝试取出队列的数据进行编码
m_encodeThread = new thread(H264EncoderThread, this);
}
return result;
}
视频编码H264的线程中,核心工作就是取出队列数据,拿到数据格式是否要转换格式,然后送入真正的编码器开启编码:
ini
void VideoRecorder::StartH264EncoderThread(VideoRecorder *recorder) {
//停止编码且队列为空时退出循环
while (!recorder->m_exit || !recorder->m_frameQueue.Empty()){
if(recorder->m_frameQueue.Empty()) {
//队列为空,休眠等待
usleep(10 * 1000);
continue;
}
//从队列中取一帧预览帧准备编码
NativeImage *pImage = recorder->m_frameQueue.Pop();
//取出的自定义对象中的属性对 AVFrame 进行设置
AVFrame *pFrame = recorder->m_pFrame;
AVPixelFormat srcPixFmt = AV_PIX_FMT_YUV420P;
switch (pImage->format) {
case IMAGE_FORMAT_RGBA:
srcPixFmt = AV_PIX_FMT_RGBA;
break;
case IMAGE_FORMAT_NV21:
srcPixFmt = AV_PIX_FMT_NV21;
break;
case IMAGE_FORMAT_NV12:
srcPixFmt = AV_PIX_FMT_NV12;
break;
case IMAGE_FORMAT_I420:
srcPixFmt = AV_PIX_FMT_YUV420P;
break;
default:
break;
}
// 如果不是YUV420P需要转换格式
if(srcPixFmt != AV_PIX_FMT_YUV420P) {
if(recorder->m_SwsContext == nullptr) {
recorder->m_SwsContext = sws_getContext(pImage->width, pImage->height, srcPixFmt,
recorder->m_frameWidth, recorder->m_frameHeight, AV_PIX_FMT_YUV420P,
SWS_FAST_BILINEAR, nullptr, nullptr, nullptr);
}
//转换为编码器的目标格式 AV_PIX_FMT_YUV420P
if(recorder->m_SwsContext != nullptr) {
int slice = sws_scale(recorder->m_SwsContext, pImage->ppPlane, pImage->pLineSize, 0,
recorder->m_frameHeight, pFrame->data, pFrame->linesize);
}
}
//设置 pts ,它代表了帧的相对时间。它与视频帧的解码顺序和显示顺序相关,为了确保正确的音视频同步和连续播放,需要使用合适的 pts 值来对视频帧进行时间排序。
pFrame->pts = recorder->m_frameIndex++;
//最终参与编码的还是 AVFrame 对象
recorder->EncodeFrame(pFrame);
free(pImage->ppPlane[0]);
pImage->ppPlane[0] = nullptr;
pImage->ppPlane[1] = nullptr;
pImage->ppPlane[2] = nullptr;
delete pImage;
}
}
具体的编码执行:
scss
//具体真正执行编码为H264
int VideoRecorder::EncodeFrame(AVFrame *pFrame) {
int result = 0;
//这个方法用于将音频帧发送给音频编码器进行编码
// 第一个参数是音频编码器的上下文(AVCodecContext),第二个参数是待编码的音频帧(AVFrame)这个方法会将音频帧送入编码器进行处理,不会立即返回编码的数据
result = avcodec_send_frame(m_pCodecCtx, pFrame);
if(result < 0){
return result;
}
while(!result) {
//这个方法用于从编码器接收编码后的数据包
//第一个参数是音频编码器的上下文,第二个参数是用于接收编码数据包的 AVPacket 结构体 这个方法会阻塞等待编码器输出数据,直到接收到完整的编码数据包才会返回。
result = avcodec_receive_packet(m_pCodecCtx, &m_avPacket);
if (result == AVERROR(EAGAIN) || result == AVERROR_EOF) {
return 0;
} else if (result < 0) {
return result;
}
m_avPacket.stream_index = m_pStream->index;
av_packet_rescale_ts(&m_avPacket, m_pCodecCtx->time_base, m_pStream->time_base); //设置时间戳,按照目标时间基进行重新调整
m_avPacket.pos = -1;
av_interleaved_write_frame(m_pFormatCtx, &m_avPacket); //用于将音频或视频数据包写入输出容器
av_packet_unref(&m_avPacket); //释放内存
}
return 0;
}
到此整个视频编码的流程就走完了,从 Camera 拿到数据,到封装格式存入队列,到初始化编码,启动编码线程,去除队列数据,转换格式进行编码。和音频的编码是一样的流程,与硬编 MediaCodec API 也是差不多的逻辑。
3.3 音视频编码
其实我们通过上面的代码,直接弄两个队列,一个音频队列一个视频队列,不同的数据源写入到不同的队列,不同的线程处理不同类型的编码,最终写入到 MP4 文件一样的可以完成音视频的编码。
但是就没有处理音视频同步的问题,同样的问题,我们 MediaCodec 硬编 API 是怎么处理音视频同步的问题的?
它是通过 MediaFormat 对象配置编码器的参数。在将音频或视频数据传递给 MediaCodec 进行硬编码之前,需要为每个输入数据设置正确的时间戳。对于音频数据,可以使用采样点的时间戳;对于视频数据,可以使用帧的时间戳。确保时间戳准确可以保证编码后的音视频数据能够以正确的顺序播放。
使用 FFmpeg 的软编也是使用类似的思路。我们把音频流,视频流,时间戳,AVFrame,AVCodecContext 等信息封装到一个对象 AVOutputStream 中。
在启动录制的时候进行初始化,并封装对应的配置信息:
ini
int MediaRecorder::StartRecord() {
int result = 0;
do {
// 用于创建并初始化一个输出容器上下文,并分配相关的内存空间。
avformat_alloc_output_context2(&m_FormatCtx, NULL, NULL, m_OutUrl);
if (!m_FormatCtx) {
avformat_alloc_output_context2(&m_FormatCtx, NULL, "mpeg", m_OutUrl);
}
if (!m_FormatCtx) {
result = -1;
break;
}
m_OutputFormat = m_FormatCtx->oformat;
//定义参数,封装参数到自定义对象AVOutputStream中
if (m_OutputFormat->video_codec != AV_CODEC_ID_NONE) {
AddStream(&m_VideoStream, m_FormatCtx, &m_VideoCodec, m_OutputFormat->video_codec);
m_EnableVideo = 1;
}
if (m_OutputFormat->audio_codec != AV_CODEC_ID_NONE) {
AddStream(&m_AudioStream, m_FormatCtx, &m_AudioCodec, m_OutputFormat->audio_codec);
m_EnableAudio = 1;
}
// 参数都设置好了,可以打开音频和视频编解码器并分配必要的编码缓冲区
if (m_EnableVideo)
OpenVideo(m_FormatCtx, m_VideoCodec, &m_VideoStream);
if (m_EnableAudio)
OpenAudio(m_FormatCtx, m_AudioCodec, &m_AudioStream);
//用于调试,打印每个流的信息
av_dump_format(m_FormatCtx, 0, m_OutUrl, 1);
//设置写入到指定本地文件
if (!(m_OutputFormat->flags & AVFMT_NOFILE)) {
int ret = avio_open(&m_FormatCtx->pb, m_OutUrl, AVIO_FLAG_WRITE);
if (ret < 0) {
result = -1;
break;
}
}
//写入媒体容器格式的文件头
result = avformat_write_header(m_FormatCtx, nullptr);
if (result < 0) {
result = -1;
break;
}
} while (false);
if (result >= 0) {
if (m_pMediaThread == nullptr)
//同时开启音频与视频的编码
m_pMediaThread = new thread(StartMediaEncodeThread, this);
}
return result;
}
具体的封装 AVOutputStream 的方法 AddStream 代码如下:
ini
void MediaRecorder::AddStream(AVOutputStream *ost, AVFormatContext *oc, AVCodec **codec, AVCodecID codec_id) {
AVCodecContext *c;
int i;
/* find the encoder */
*codec = avcodec_find_encoder(codec_id);
if (!(*codec)) {
return;
}
ost->m_pStream = avformat_new_stream(oc, NULL);
if (!ost->m_pStream) {
return;
}
ost->m_pStream->id = oc->nb_streams - 1;
c = avcodec_alloc_context3(*codec);
if (!c) {
return;
}
ost->m_pCodecCtx = c;
switch ((*codec)->type) {
case AVMEDIA_TYPE_AUDIO:
c->sample_fmt = (*codec)->sample_fmts ?
(*codec)->sample_fmts[0] : AV_SAMPLE_FMT_FLTP;
c->bit_rate = 96000;
c->sample_rate = m_RecorderParam.audioSampleRate;
c->channel_layout = m_RecorderParam.channelLayout;
c->channels = av_get_channel_layout_nb_channels(c->channel_layout);
ost->m_pStream->time_base = (AVRational) {1, c->sample_rate}; //音频流的采样率,需要根据音频流还是视频流的不同,分别设置了正确的时间基准
break;
case AVMEDIA_TYPE_VIDEO:
c->codec_id = codec_id;
c->bit_rate = m_RecorderParam.videoBitRate;
c->width = m_RecorderParam.frameWidth;
c->height = m_RecorderParam.frameHeight;
ost->m_pStream->time_base = (AVRational) {1, m_RecorderParam.fps}; //视频流的帧率,需要根据音频流还是视频流的不同,分别设置了正确的时间基准
c->time_base = ost->m_pStream->time_base;
c->gop_size = 12;
c->pix_fmt = AV_PIX_FMT_YUV420P;
if (c->codec_id == AV_CODEC_ID_MPEG2VIDEO) {
c->max_b_frames = 2;
}
if (c->codec_id == AV_CODEC_ID_MPEG1VIDEO) {
c->mb_decision = 2;
}
break;
default:
break;
}
if (oc->oformat->flags & AVFMT_GLOBALHEADER)
c->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
}
参数设置好之后,初始化并分配内存空间等代码和上面单独的录制比较类似,这里就不贴代码。
启动了编码的线程之后,我们就可以从封装的 AVOutputStream 对象中拿到对应的时间戳,根据音频时间戳与视频时间戳对比启动不同的编码循环
rust
void MediaRecorder::StartMediaEncodeThread(MediaRecorder *recorder) {
AVOutputStream *vost = &recorder->m_VideoStream;
AVOutputStream *aost = &recorder->m_AudioStream;
while (!vost->m_EncodeEnd || !aost->m_EncodeEnd) {
//得到音频时间戳与视频时间戳
double videoTimestamp = vost->m_NextPts * av_q2d(vost->m_pCodecCtx->time_base);
double audioTimestamp = aost->m_NextPts * av_q2d(aost->m_pCodecCtx->time_base);
//比较两个时间戳的大小关系
if (!vost->m_EncodeEnd &&
(aost->m_EncodeEnd || av_compare_ts(vost->m_NextPts, vost->m_pCodecCtx->time_base,
aost->m_NextPts, aost->m_pCodecCtx->time_base) <= 0)) {
//视频和音频时间戳对齐
if (audioTimestamp <= videoTimestamp && aost->m_EncodeEnd) vost->m_EncodeEnd = 1;
vost->m_EncodeEnd = recorder->EncodeVideoFrame(vost);
} else {
aost->m_EncodeEnd = recorder->EncodeAudioFrame(aost);
}
}
}
具体的音频编码,直接从队列取出数据,给 FFmpeg 编码,完成之后直接写入封装格式中:
ini
int MediaRecorder::EncodeAudioFrame(AVOutputStream *ost) {
int result = 0;
AVCodecContext *c;
AVPacket pkt = {0}; // data and size must be 0;
AVFrame *frame;
int ret;
int dst_nb_samples;
av_init_packet(&pkt);
c = ost->m_pCodecCtx;
while (m_AudioFrameQueue.Empty() && !m_Exit) {
usleep(10 * 1000);
}
//取出音频数据进行处理
AudioFrame *audioFrame = m_AudioFrameQueue.Pop();
//最终需要处理的还是 AVFrame
frame = ost->m_pTmpFrame;
if (audioFrame) {
frame->data[0] = audioFrame->data;
frame->nb_samples = audioFrame->dataSize / 4;
frame->pts = ost->m_NextPts;
ost->m_NextPts += frame->nb_samples;
}
if ((m_AudioFrameQueue.Empty() && m_Exit) || ost->m_EncodeEnd) frame = nullptr;
if (frame) {
//对时间戳或帧数进行不同时间基准之间的转换,或者将帧率从一种格式转换为另一种格式。
dst_nb_samples = av_rescale_rnd(swr_get_delay(ost->m_pSwrCtx, c->sample_rate) + frame->nb_samples,
c->sample_rate, c->sample_rate, AV_ROUND_UP);
av_assert0(dst_nb_samples == frame->nb_samples);
//设置可写
ret = av_frame_make_writable(ost->m_pFrame);
if (ret < 0) {
result = 1;
goto EXIT;
}
//通过 swr_convert 函数进行音频重采样,可以实现不同采样格式之间的兼容和转换
ret = swr_convert(ost->m_pSwrCtx,
ost->m_pFrame->data, dst_nb_samples,
(const uint8_t **) frame->data, frame->nb_samples);
if (ret < 0) {
result = 1;
goto EXIT;
}
frame = ost->m_pFrame;
//设置pts,之前说过
frame->pts = av_rescale_q(ost->m_SamplesCount, (AVRational) {1, c->sample_rate}, c->time_base);
ost->m_SamplesCount += dst_nb_samples;
}
//将音频帧发送给音频编码器进行编码
ret = avcodec_send_frame(c, frame);
if (ret == AVERROR_EOF) {
result = 1;
goto EXIT;
} else if (ret < 0) {
result = 0;
goto EXIT;
}
while (!ret) {
//从编码器接收编码后的数据包
ret = avcodec_receive_packet(c, &pkt);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
result = 0;
goto EXIT;
} else if (ret < 0) {
result = 0;
goto EXIT;
}
//编码后的数据写入到封装包
int result = WritePacket(m_FormatCtx, &c->time_base, ost->m_pStream, &pkt);
if (result < 0) {
result = 0;
goto EXIT;
}
}
EXIT:
if (audioFrame) delete audioFrame;
return result;
}
视频的编码也是同样的套路,也是上面单独视频编码的那一套代码,只是在编码完成之后送入了封装格式去处理:
ini
int MediaRecorder::EncodeVideoFrame(AVOutputStream *ost) {
int result = 0;
int ret;
AVCodecContext *c;
AVFrame *frame;
AVPacket pkt = {0};
c = ost->m_pCodecCtx;
av_init_packet(&pkt);
while (m_VideoFrameQueue.Empty() && !m_Exit) {
usleep(10 * 1000);
}
frame = ost->m_pTmpFrame;
AVPixelFormat srcPixFmt = AV_PIX_FMT_YUV420P;
//取出视频数据进行编码
VideoFrame *videoFrame = m_VideoFrameQueue.Pop();
if (videoFrame) {
frame->data[0] = videoFrame->ppPlane[0];
frame->data[1] = videoFrame->ppPlane[1];
frame->data[2] = videoFrame->ppPlane[2];
frame->linesize[0] = videoFrame->pLineSize[0];
frame->linesize[1] = videoFrame->pLineSize[1];
frame->linesize[2] = videoFrame->pLineSize[2];
frame->width = videoFrame->width;
frame->height = videoFrame->height;
switch (videoFrame->format) {
case IMAGE_FORMAT_RGBA:
srcPixFmt = AV_PIX_FMT_RGBA;
break;
case IMAGE_FORMAT_NV21:
srcPixFmt = AV_PIX_FMT_NV21;
break;
case IMAGE_FORMAT_NV12:
srcPixFmt = AV_PIX_FMT_NV12;
break;
case IMAGE_FORMAT_I420:
srcPixFmt = AV_PIX_FMT_YUV420P;
break;
default:
break;
}
}
if ((m_VideoFrameQueue.Empty() && m_Exit) || ost->m_EncodeEnd) frame = nullptr;
if (frame != nullptr) {
//设置可写
if (av_frame_make_writable(ost->m_pFrame) < 0) {
result = 1;
goto EXIT;
}
//转换格式
if (srcPixFmt != AV_PIX_FMT_YUV420P) {
if (!ost->m_pSwsCtx) {
ost->m_pSwsCtx = sws_getContext(c->width, c->height,
srcPixFmt,
c->width, c->height,
c->pix_fmt,
SWS_FAST_BILINEAR, nullptr, nullptr, nullptr);
if (!ost->m_pSwsCtx) {
result = 1;
goto EXIT;
}
}
sws_scale(ost->m_pSwsCtx, (const uint8_t *const *) frame->data,
frame->linesize, 0, c->height, ost->m_pFrame->data,
ost->m_pFrame->linesize);
}
//设置pts,都是之前的代码
ost->m_pFrame->pts = ost->m_NextPts++;
frame = ost->m_pFrame;
}
// 将视频帧发送给音频编码器进行编码
ret = avcodec_send_frame(c, frame);
if (ret == AVERROR_EOF) {
result = 1;
goto EXIT;
} else if (ret < 0) {
result = 0;
goto EXIT;
}
while (!ret) {
//从编码器接收编码后的数据包
ret = avcodec_receive_packet(c, &pkt);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
result = 0;
goto EXIT;
} else if (ret < 0) {
result = 0;
goto EXIT;
}
//编码后的数据写入到封装包
int result = WritePacket(m_FormatCtx, &c->time_base, ost->m_pStream, &pkt);
if (result < 0) {
result = 0;
goto EXIT;
}
}
EXIT:
free(videoFrame->ppPlane[0]);
videoFrame->ppPlane[0] = nullptr;
videoFrame->ppPlane[1] = nullptr;
videoFrame->ppPlane[2] = nullptr;
if (videoFrame) delete videoFrame;
return result;
}
具体的写入到封装格式的方法:
rust
int MediaRecorder::WritePacket(AVFormatContext *fmt_ctx, AVRational *time_base, AVStream *st, AVPacket *pkt) {
// 重新调整音视频数据包(AVPacket)的时间戳
av_packet_rescale_ts(pkt, *time_base, st->time_base);
pkt->stream_index = st->index;
fmt_ctx->streams[pkt->stream_index]->time_base;
//将音频或视频帧写入容器格式文件
return av_interleaved_write_frame(fmt_ctx, pkt);
}
至于如何从 Camera 和 AudioRecord 获取到数据源并封装对应的格式,放入到对应的队列,这个就不多说了,和上面单独的录制是一样的代码。
后记
最终实现的效果和硬编实现的效果是一样的,只是在低端手机上会出现等待编码的效果,一般的做法是展示 Loading 弹窗等待,不能达到硬编API的那种秒编的效果。
软编和硬编的优缺点就不再重复说了,现在最新的 FFmpeg 6.0 版本都支持 JNI 直接调用设备硬编了,以后用 FFmpeg 编码兼容性可以更好做。
本文的全部代码部分在我的项目中 【传送门】,FFmpeg 的项目大家也可以参考字节流动老师的项目与博客【传送门】 ,我都是参考大佬实现的。
对于音视频我是小白,感谢流动老师的无私开源让我这个门外汉也能开发出简单的音视频效果。
关于软编的实现,基本上只需要了解一点JNI的知识,和对应库的API文档,就可以实现出对应的效果,并不是特别的难,例如 FFmpeg ,如果大家没有接触它的 API 就感觉有点抓瞎,当然了其实不了解它的 API 我们参考部分开源项目也能实现对应的功能,因为大部分都是一些比较固定的写法,只是后期的瘦身精简与优化之类的有点吃力了。如果大家对音视频有兴趣想深入学习或了解,可以参考大佬的博客,都有详细的教程,FFmpeg 与 OpenGL 的文章都特别的好。
关于后期的特效音视频的录制,添加背景音频,简单剪辑实现与导出。后期看情况再分享一下(其实都是网上开源的方案)...
如果本文的讲解有什么错漏的地方,希望同学们一定要指出哦。有疑问也可以评论区交流学习进步,谢谢!
当然如果觉得本文还不错对你有些帮助的话,还请点赞
支持一下哦,你的支持是我最大的动力啦!
Ok,这一期就此完结。