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) 及以上
目标设备:手机(真机)
权限:相机权限 + 麦克风权限 + 媒体读写权限

核心实现:从录制到转码的完整链路

整个流程分三步:

  1. Camera Kit + AVRecorder 录制视频
  2. AVCodec 解码原始视频
  3. 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%。
  • videoCodecVIDEO_AVC(H.264),兼容性最好。
  • fileFormatCFT_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中的widthheightbitRate是压缩效果的核心参数。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;
  }
}
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐