HarmonyOS技术精讲-Camera Kit(相机服务)第17篇:结合AVCodec进行视频编解码
HarmonyOS技术精讲-Camera Kit(相机服务)第17篇:结合AVCodec进行视频编解码

从实际问题说起
用Camera Kit直接录制的视频,默认编码参数是按最高质量配置的。一个1080p、30fps、H.264的视频,每分钟大概占100-150MB。这个体积对本地存储来说还能接受,但如果要做上传、分享、或者作为素材二次处理,就太大了。
很多团队的实际做法是:先录,再转码压缩。Camera Kit负责采集,AVCodec负责加工,两者通过视频文件或SurfaceBuffer接力。
但这里有一个容易被忽略的问题——AVRecorder输出的视频格式和AVCodec的输入格式之间,存在参数不匹配的风险。直接丢进去转码,经常会遇到配置失败、编码器报错、或者转出来花屏的情况。
这篇文章会从录像开始,走完一个完整的录制→转码压缩→提取封面帧的流程。重点放在AVRecorder和AVCodec的对接细节上,代码全部可运行,不省略关键逻辑。
这个方案解决什么问题
Camera + AVCodec的组合,主要解决三个场景:
| 场景 | 传统做法 | 推荐方案 |
|---|---|---|
| 视频文件太大 | 录完再手动用工具压缩 | 代码自动转码,设置目标码率 |
| 格式不统一 | 依赖系统录制格式 | 统一转码为H.264+AAC |
| 需要视频封面 | 用第三方库提取帧 | AVCodec解码+PixelMap转换 |
不适合的场景:实时滤镜、实时美颜、低延迟直播。这些需要走Camera的preview输出Surface给AVCodec做实时处理,不是本文讨论的范围。
环境说明
DevEco Studio 版本:6.1.0 及以上
HarmonyOS SDK 版本:6.1.0(23) 及以上
目标设备:手机(真机)
权限:相机权限 + 麦克风权限 + 媒体读写权限
核心实现:从录制到转码的完整链路
整个流程分三步:
- Camera Kit + AVRecorder 录制视频
- AVCodec 解码原始视频
- AVCodec 编码输出压缩后的视频
最后单独加一个提取视频帧的环节,用于生成封面图。
1. 相机预览与录像
先初始化相机,创建AVRecorder实例,配置录制参数后开始录制。这里的关键是AVRecorder的配置参数要和后续转码的解码器输入参数对齐。
import { camera } from '@kit.CameraKit';
import { media } from '@kit.MediaKit';
import { common } from '@kit.AbilityKit';
async function startRecording(context: common.Context, surfaceId: string): Promise<string> {
// 创建AVRecorder实例
const avRecorder: media.AVRecorder = await media.createAVRecorder();
// 配置录制参数
const recorderProfile: media.AVRecorderProfile = {
audioBitrate: 96000,
audioChannels: 2,
audioCodec: media.CodecMimeType.AUDIO_AAC,
audioSampleRate: 48000,
fileFormat: media.ContainerFormatType.CFT_MPEG_4,
videoBitrate: 2000000,
videoCodec: media.CodecMimeType.VIDEO_AVC,
videoFrameWidth: 1920,
videoFrameHeight: 1080,
videoFrameRate: 30
};
const recorderConfig: media.AVRecorderConfig = {
audioSourceType: media.AudioSourceType.AUDIO_SOURCE_TYPE_MIC,
videoSourceType: media.VideoSourceType.VIDEO_SOURCE_TYPE_SURFACE_YUV,
context: context,
profile: recorderProfile,
url: 'file:///data/storage/el2/base/haps/entry/files/raw_video.mp4',
rotation: 0
};
await avRecorder.prepare(recorderConfig);
// 获取录制Surface用于相机输出
const videoSurfaceId: string = await avRecorder.getInputSurface();
// 将SurfaceId传给Camera启动预览和录制
await startCameraPreview(surfaceId, videoSurfaceId);
await avRecorder.start();
return recorderConfig.url;
}
为什么这样写:
videoBitrate设置为2Mbps,这是一个折中值。后续转码时我们会降到1Mbps,压缩50%。videoCodec用VIDEO_AVC(H.264),兼容性最好。fileFormat用CFT_MPEG_4,AVCodec对MP4容器支持最完善。
2. 视频转码压缩
原始视频录制完成后,用AVCodec做转码。核心逻辑是:解码器读取原始视频,编码器按新的参数重新编码。
import { media } from '@kit.MediaKit';
import { image } from '@kit.ImageKit';
async function transcodeVideo(inputPath: string, outputPath: string): Promise<void> {
// 创建AVSource读取视频源
const avSource: media.AVSource = await media.createAVSource(inputPath);
const sourceInfo: media.AVSourceInfo = avSource.getSourceInfo();
// 获取视频轨道信息
const trackInfoList: media.AVTrackInfo[] = avSource.getTrackInfo();
let videoTrackIndex: number = -1;
for (let i = 0; i < trackInfoList.length; i++) {
if (trackInfoList[i].trackType === media.MediaType.VIDEO_TRACK_TYPE) {
videoTrackIndex = i;
break;
}
}
if (videoTrackIndex === -1) {
console.error('No video track found');
return;
}
// 创建解码器和编码器
const videoDecoder: media.VideoDecoder = await media.createVideoDecoder();
const videoEncoder: media.VideoEncoder = await media.createVideoEncoder();
// 配置解码器(读取原始视频参数)
const decodeFormat: media.Format = {
codecMime: media.CodecMimeType.VIDEO_AVC,
width: 1920,
height: 1080,
pixelFormat: media.PixelFormat.YUV_420_SP,
frameRate: 30
};
// 配置编码器(输出压缩参数)
const encodeFormat: media.Format = {
codecMime: media.CodecMimeType.VIDEO_AVC,
width: 1280, // 分辨率降级
height: 720,
bitRate: 1000000, // 码率降到1Mbps
pixelFormat: media.PixelFormat.YUV_420_SP,
frameRate: 24, // 帧率降为24fps
iFrameInterval: 30
};
// 配置并启动解码器
await videoDecoder.configure(decodeFormat, avSource.getNativeBuffer(videoTrackIndex));
await videoDecoder.prepare();
await videoDecoder.start();
// 配置并启动编码器
const outputFd: number = fs.openSync(outputPath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY);
const outputSurface: media.OutputSurface = await videoEncoder.getInputSurface();
await videoEncoder.configure(encodeFormat, outputSurface);
await videoEncoder.prepare();
await videoEncoder.start();
// 编解码循环:逐帧处理
let frameCount: number = 0;
const maxFrames: number = 30 * 60; // 最多处理60秒视频
while (frameCount < maxFrames) {
const inputBuffer: media.Buffer = await videoDecoder.getInputBuffer();
if (!inputBuffer) {
break;
}
// 填充解码器输入(从AVSource读取)
const sample: media.AVSample = avSource.readSample(videoTrackIndex);
if (!sample || sample.flags === media.BufferFlag.END_OF_STREAM) {
await videoDecoder.pushInputBuffer(inputBuffer.index, media.BufferFlag.END_OF_STREAM);
break;
}
inputBuffer.write(sample.data);
await videoDecoder.pushInputBuffer(inputBuffer.index, media.BufferFlag.NONE);
// 获取解码输出
const outputBuffer: media.Buffer = await videoDecoder.getOutputBuffer();
if (outputBuffer) {
// 将解码后的数据送入编码器
const encodeInput: media.Buffer = await videoEncoder.getInputBuffer();
if (encodeInput) {
encodeInput.write(outputBuffer.read());
await videoEncoder.pushInputBuffer(encodeInput.index, media.BufferFlag.NONE);
}
await videoDecoder.releaseOutputBuffer(outputBuffer.index);
}
frameCount++;
}
// 结束编码
await videoEncoder.pushInputBuffer(-1, media.BufferFlag.END_OF_STREAM);
// 等待编码器完成
while (true) {
const encodeOutput: media.Buffer = await videoEncoder.getOutputBuffer();
if (!encodeOutput || encodeOutput.flags === media.BufferFlag.END_OF_STREAM) {
break;
}
// 写入输出文件
fs.writeSync(outputFd, encodeOutput.read());
await videoEncoder.releaseOutputBuffer(encodeOutput.index);
}
// 清理
await videoEncoder.stop();
await videoEncoder.release();
await videoDecoder.stop();
await videoDecoder.release();
avSource.release();
fs.closeSync(outputFd);
}
这段代码的关键点:
decodeFormat必须和录制时的参数匹配,否则解码器初始化失败。encodeFormat中的width、height、bitRate是压缩效果的核心参数。1280x720 + 1Mbps,压缩率大约50%-60%。iFrameInterval设置为30,意味着每30帧一个关键帧,适合后续做帧提取。
实际项目中这个循环需要做性能优化。上面的写法是同步风格的示例,真实场景建议用双缓冲+异步回调,避免编解码互相等待。
3. 提取视频帧作为封面
转码完成后,从视频中提取第一帧作为封面图。这里复用上一步的解码逻辑,只解码一个关键帧。
async function extractVideoFrame(videoPath: string, outputImagePath: string): Promise<void> {
const avSource: media.AVSource = await media.createAVSource(videoPath);
const trackInfoList: media.AVTrackInfo[] = avSource.getTrackInfo();
let videoTrackIndex: number = -1;
for (let i = 0; i < trackInfoList.length; i++) {
if (trackInfoList[i].trackType === media.MediaType.VIDEO_TRACK_TYPE) {
videoTrackIndex = i;
break;
}
}
if (videoTrackIndex === -1) {
console.error('No video track');
return;
}
// 只解码第一帧
const videoDecoder: media.VideoDecoder = await media.createVideoDecoder();
const decodeFormat: media.Format = {
codecMime: media.CodecMimeType.VIDEO_AVC,
width: 1280,
height: 720,
pixelFormat: media.PixelFormat.YUV_420_SP,
frameRate: 24
};
await videoDecoder.configure(decodeFormat, avSource.getNativeBuffer(videoTrackIndex));
await videoDecoder.prepare();
await videoDecoder.start();
// 读取第一个关键帧
let frameExtracted: boolean = false;
while (!frameExtracted) {
const sample: media.AVSample = avSource.readSample(videoTrackIndex);
if (!sample || sample.flags === media.BufferFlag.END_OF_STREAM) {
break;
}
// 只处理关键帧
if (sample.flags === media.BufferFlag.KEY_FRAME) {
const inputBuffer: media.Buffer = await videoDecoder.getInputBuffer();
if (inputBuffer) {
inputBuffer.write(sample.data);
await videoDecoder.pushInputBuffer(inputBuffer.index, media.BufferFlag.NONE);
const outputBuffer: media.Buffer = await videoDecoder.getOutputBuffer();
if (outputBuffer) {
// 将YUV数据转换为PixelMap
const pixelMap: image.PixelMap = await image.createPixelMap(outputBuffer.read(), {
width: 1280,
height: 720,
pixelFormat: image.PixelMapFormat.RGBA_8888
});
// 保存为JPEG
const packer: image.ImagePacker = image.createImagePacker();
const encodeOptions: image.PackingOption = {
format: 'image/jpeg',
quality: 90
};
const packedData: ArrayBuffer = await packer.packing(pixelMap, encodeOptions);
const fd: number = fs.openSync(outputImagePath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY);
fs.writeSync(fd, packedData);
fs.closeSync(fd);
packer.release();
pixelMap.release();
frameExtracted = true;
}
}
}
}
await videoDecoder.stop();
await videoDecoder.release();
avSource.release();
}
提取帧的要点:
- 只解码关键帧(
KEY_FRAME),避免解码整个视频。 iFrameInterval设为30,所以最多解码30帧就能找到关键帧。- YUV转RGBA再编码为JPEG,这个转换在API 23上由
createPixelMap自动完成。
常见问题:为什么转码后视频反而变大了
现象:设置了更低的码率,转码后文件体积不降反升。
原因:iFrameInterval 设得太小(比如1),导致每一帧都是关键帧。关键帧数据量远大于非关键帧,体积会膨胀。
解法:把 iFrameInterval 设为30-60。如果视频是短视频(<10秒),设为10就够了。
// 正确设置
const encodeFormat: media.Format = {
// ... 其他参数
iFrameInterval: 30 // 每30帧一个关键帧
};
常见问题:编码器配置失败(-1)
现象:videoEncoder.configure() 返回-1或者抛出异常。
原因:最常见的是 视频宽高不符合编码器的对齐要求。H.264编码器要求宽高都是16的倍数。
解法:配置前做对齐处理。
function alignTo16(value: number): number {
return Math.floor(value / 16) * 16;
}
// 配置前对齐
encodeFormat.width = alignTo16(1280); // 1280
encodeFormat.height = alignTo16(720); // 720
如果原始视频是1920x1080(1080p),对齐后还是1920x1080。但如果是自定义分辨率比如1500x900,对齐后变成1488x896,这个差异在编码器层面是安全的。
常见问题:解码器输出Buffer为空
现象:videoDecoder.getOutputBuffer() 返回null或者undefined。
原因:解码器的输出Buffer是有限资源,如果没有及时释放,后续帧就无法输出。这在循环处理中特别容易出现。
解法:每获取一个输出Buffer,处理后立即释放。
// 错误写法:先收集所有Buffer再释放
const buffers = [];
while (buffer = await decoder.getOutputBuffer()) {
buffers.push(buffer); // 持有所有Buffer引用
}
// 最后才释放 -> 内存占用飙升,编解码卡死
// 正确写法:处理一帧释放一帧
const outputBuffer = await decoder.getOutputBuffer();
if (outputBuffer) {
// 处理数据
processFrame(outputBuffer);
// 立即释放
await decoder.releaseOutputBuffer(outputBuffer.index);
}
最佳实践
1. 录制参数和转码参数用同一个常量定义
避免两边手动写两套参数,写错一个就对接不上。
const VIDEO_CONFIG = {
width: 1920,
height: 1080,
bitrate: 2000000,
fps: 30
} as const;
录制和转码都引用这个常量,转码时只在需要调整的参数上做覆盖。
2. 编解码循环用异步生成器模式
上面的示例里用了while循环 + await,实际项目中建议用AsyncGenerator,代码更清晰,且便于增加超时控制。
async function* frameGenerator(decoder: media.VideoDecoder): AsyncGenerator<media.Buffer> {
while (true) {
const buffer = await decoder.getOutputBuffer();
if (!buffer || buffer.flags === media.BufferFlag.END_OF_STREAM) {
break;
}
yield buffer;
await decoder.releaseOutputBuffer(buffer.index);
}
}
// 使用
for await (const frame of frameGenerator(decoder)) {
// 处理帧
}
3. 提取帧时优先用关键帧而非时间戳
用seekToTime()跳到指定时间再解码,需要先找到最近的的关键帧,实际解码的是关键帧而非目标帧。不如直接遍历前几个关键帧,更可控。
4. 转码完成后务必校验文件完整性
调用fs.statSync()检查输出文件大小,如果小于1KB大概率是失败文件。再尝试用AVSource打开验证。
function verifyVideoFile(path: string): boolean {
try {
const stat = fs.statSync(path);
if (stat.size < 1024) {
return false;
}
const source = media.createAVSource(path);
source.release();
return true;
} catch {
return false;
}
}
更多推荐

所有评论(0)