图片/视频操作一条龙(2):视频转码,缩略如获取(鸿蒙开发)

133 阅读10分钟

上一篇文章我们详细地讨论了图片相关的操作,最近有点忙时隔多日没有更新了,这一次我们继续视频图片操作一条龙系列--一起来捋一捋视频相关的操作--主要包含视频转码和视频缩略图的获取。

视频播放—AVPlayer和Video的区别

developer.huawei.com/consumer/cn…

正如官方文档所提到的:

  • AVPlayer:功能较完善的音视频播放ArkTS/JS API,集成了流媒体和本地资源解析,媒体资源解封装,视频解码和渲染功能,适用于对媒体资源进行端到端播放的场景,可直接播放mp4、mkv等格式的视频文件。
  • Video组件:封装了视频播放的基础能力,需要设置数据源以及基础信息即可播放视频,但相对扩展能力较弱。Video组件由ArkUI提供能力,相关指导请参考UI开发文档-Video组件

简单概述就是Video能用但是不如AVPlayer扩展性好,灵活性高,不过AVPlayer好像没有设置缩略图的属性呀,这点Video更胜一筹。

视频转码

developer.huawei.com/consumer/cn… 我们先来了解下相关概念:

码率

  • 码率:指的是单位时间内视频流的数据量(单位:Kbps 或 Mbps)。1 Mbps = 1,000,000 bit/s(1,000,000比特每秒)。

    码率越高,单位时间内传输的数据越多,潜在画质更高,但文件体积也更大。

分辨率

  • 分辨率:指视频画面的像素数量(例如 1920×1080—宽×高)。

    分辨率越高,像素数量越多,画面细节更加清晰,但需要处理的数据量也更大。

关系与转换公式

  • 直观关系

    在相同编码效率和内容复杂度的情况下,分辨率越大,则需要分配越高的码率以保持画质。如果所分配的码率不足,编码器会通过压缩(如丢弃细节、增加块效应)来降低数据量。

  • 公式参考(经验法则)

    • 码率正比于 分辨率宽×分辨率高×帧率×复杂度系数
    • 复杂度系数与视频内容的动态程度相关,例如静态画面(例如讲座视频)低复杂度系数,可以较低码率保持清晰,动态画面(例如体育比赛)高复杂度系数,需要更高的码率。
  • 码率转换

    输入:源视频的宽wref、高href、帧率fpsref、码率Rref;目标视频的宽wtar、高htar、帧率fpstar。

    输出:目标视频的码率Rtar。

    计算过程:

    分辨率和帧率的系数由以下经验公式计算可得。

    上述计算帧率的公式y=clip(0.5, 2, x)表示:如果x∈[0.5, 2.0],取y=x;如果x<0.5,取y=0.5;如果x>2.0,取y=2.0。

  • 码率计算

    选定一个baseline的码率,例如720P/30fps的视频,码率默认3Mbps,记为V0。

    如果要对视频V1做转码,输出视频为V2,可以按如下过程计算:

    1. 带入(V0,V2),得到估计码率为R2。

    2. 带入(V1,V2),得到估计码率为R2'。

      取二者最小值,以确保目标码率比源视频有所降低。

  • 分辨率设置参考(以H.264为例)

    分辨率动态内容(如游戏)中等动态(如电影)静态内容(如幻灯片)
    720p(1280 × 720)3.5–5 Mbps2.5–4 Mbps1–2 Mbps
    1080p(1920 × 1080)6–8 Mbps4–6 Mbps2–3 Mbps
    4K(3840 × 2160)25–35 Mbps15–25 Mbps10–15 Mbps
  • 转换样例

    场景一:假设要转码一个分辨率1280×720,30fps的视频,码率为1Mbps,这是画质相对比较良好的视频。需要将视频转码为分辨率640×480,30fps的视频,码率应该设置为463,463bps。计算如下:

    Resolution_factor = 0.463463

    fps_factor = 1

    Rtar = 463,463bps

    场景二:假设要转码一个分辨率1280×720,30fps的视频,码率为1Mbps的视频。需要将视频转码为码率为600,000bps,30fps的视频,分辨率应该设置为888×500。计算如下:

    fps_factor = 1

    Rtar = 600,000bps

    Resolution_factor = 0.482029

现在需要获取视频元数据根据经验公式动态计算转码配置

获取宽高,码率,帧率等信息:developer.huawei.com/consumer/cn…

developer.huawei.com/consumer/cn…

不使用media.AVMetadataExtractor是因为其只能获取到宽高等信息但不含有码率与帧率:

developer.huawei.com/consumer/cn…

代码示例:

获取视频元数据并计算转码配置

export interface VideoTrackInfo { // ④ 显式声明接口,替代 Object literal
  width: number;
  height: number;
  bitrate: number;
  frameRate: number;
}

/* 可调整的“输出模板” */
const TARGET_WIDTH = 1280;
const TARGET_HEIGHT = 720;
const TARGET_FPS = 30;

/* H.264 中等动态内容 baseline(截图表格) */
const BASELINE_MBPS: Record<string, number> = {
  '720p': 3, // 2.5-4 Mbps 取中值
  '1080p': 5, // 4-6 Mbps
  '4K': 20, // 15-25 Mbps
};

/* 分辨率档次判断 */
function getResolutionLevel(w: number, h: number): string {
  if (w >= 3840 || h >= 2160) {
    return '4K';
  }
  if (w >= 1920 || h >= 1080) {
    return '1080p';
  }
  return '720p';
}

/* clip 工具 */
function clip(min: number, max: number, v: number): number {
  return Math.max(min, Math.min(max, v));
}

  /**
   * 传入本地文件路径,返回视频主轨信息
   */
  async getVideoTrack(uri: string): Promise<VideoTrackInfo> {
    console.info('获取视频详细信息');
    let srcFd = -1;
    let player: media.AVPlayer | null = null;

    try {
      /* 1. 打开文件 */
      const srcFile = uri.includes('://')
        ? fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY)
        : fs.openSync(uri, fs.OpenMode.READ_ONLY);
      srcFd = srcFile.fd;

      /* 2. 创建播放器 */
      player = await media.createAVPlayer();
      player.fdSrc = { fd: srcFd };

      /* 3. 等「initialized」→ 再 prepare → 再等「prepared」 */
      await new Promise<void>((res, rej) => {
        player!.on('stateChange', (s: string) => {
          if (s === 'initialized') {
            player!.prepare(); // 只有 initialized 才能调
          }
          if (s === 'prepared') {
            res(); // prepared 完成才能往下走
          }
          if (s === 'error') {
            rej(new Error('prepare fail'));
          }
        });
      });

      /* 4. 现在状态 = prepared,可以安全读轨道 */
      const tracks = await player.getTrackDescription();
      for (const t of tracks) {
        if (t[media.MediaDescriptionKey.MD_KEY_TRACK_TYPE] === media.MediaType.MEDIA_TYPE_VID) {
          const width = t[media.MediaDescriptionKey.MD_KEY_WIDTH] as number;
          const height = t[media.MediaDescriptionKey.MD_KEY_HEIGHT] as number;
          const bitrate = t[media.MediaDescriptionKey.MD_KEY_BITRATE] as number;
          const frameRate = t[media.MediaDescriptionKey.MD_KEY_FRAME_RATE] as number;
          console.info(`视频轨 ${width}×${height} 码率=${bitrate} bps 帧率=${frameRate / 100} fps`);
          return {
            width,
            height,
            bitrate,
            frameRate: frameRate / 100
          };
        }
      }
      throw new Error('无视频轨');
    } finally {
      if (player) {
        await player.release();
      }
      if (srcFd !== -1) {
        fileIo.closeSync(srcFd);
      }
    }
  }
  
   async setConfig(uri: string): Promise<media.AVTranscoderConfig> {
    const src = await this.getVideoTrack(uri); // 宽高、码率、帧率

    /* 1. 系数计算(截图公式) */
    const fpsFactor = clip(0.5, 2.0, (TARGET_FPS + src.frameRate) / (2 * src.frameRate));
    const resolutionFactor = (TARGET_WIDTH * TARGET_HEIGHT) / (src.width * src.height);
    const estimatedBitrate = fpsFactor * resolutionFactor * src.bitrate; // bps

    /* 2. baseline 上限 */
    const level = getResolutionLevel(TARGET_WIDTH, TARGET_HEIGHT);
    const baselineBitrate = BASELINE_MBPS[level] * 1_000_000; // bps

    /* 3. 取二者较小值,确保比源视频低 */
    const targetBitrate = Math.min(estimatedBitrate, baselineBitrate, src.bitrate);

    /* 4. 帧率同理不超过源视频 */
    const targetFrameRate = Math.min(TARGET_FPS, src.frameRate);

    console.info(`转码目标 ${TARGET_WIDTH}×${TARGET_HEIGHT} @${targetFrameRate} fps`);
    console.info(`目标码率 ${(targetBitrate / 1_000_000).toFixed(2)} Mbps`);

    /* 5. 返回 AVTranscoderConfig */
    return {
      fileFormat: media.ContainerFormatType.CFT_MPEG_4, // mp4
      videoCodec: media.CodecMimeType.VIDEO_AVC, // H.264
      audioCodec: media.CodecMimeType.AUDIO_AAC, // AAC
      videoFrameWidth: TARGET_WIDTH,
      videoFrameHeight: TARGET_HEIGHT,
      videoBitrate: targetBitrate,
      audioBitrate: 100000, // 128 kbps 默认值,按需改
    };
  }

转码:

  //  开始转码对应的流程,返回转码后文件路径
  async startTranscoderingProcess(uri: string): Promise<string> {

    const cfg = await this.setConfig(uri);

    // 下面再开始你的 AVTranscoder 流程

    if (!canIUse('SystemCapability.Multimedia.Media.AVTranscoder')) {
      return Promise.reject('AVTranscoder unavailable');
    }

    // 1. 释放旧实例
    if (this.avTranscoder) {
      await this.avTranscoder.release();
    }

    // 2. 创建新实例
    this.avTranscoder = await media.createAVTranscoder();
    this.setAVTranscoderCallback();

    let srcFd: number | null = null;
    let fileSizeMB: number | null = null;
    if (uri.includes('://')) {
      const srcFile = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
      srcFd = srcFile.fd;
      const stat = fs.statSync(srcFd);
      fileSizeMB = stat.size / (1024 * 1024);
    } else {
      const srcFile = fs.openSync(uri, fs.OpenMode.READ_ONLY);
      srcFd = srcFile.fd;
      const stat = fs.statSync(srcFd);
      fileSizeMB = stat.size / (1024 * 1024);
    }
    console.log(`转码前文件的大小为${fileSizeMB}MB`)
    // 3. 输入 fd
    // const fdSrc = await this.context?.resourceManager.getRawFd(uri);
    // this.avTranscoder.fdSrc = fdSrc!;
    const fd: media.AVFileDescriptor = { fd: srcFd!, offset: 0, length: -1 }
    this.avTranscoder.fdSrc = fd; //包装成AVFileDescriptor

    // 4. 输出文件路径 & fd
    const fileName = uri.split('/').pop() || `compressed_${Date.now()}.mp4`;
    const compressedName = `compressed_${Date.now()}_${fileName.replace(/\.[^.]+$/, '.mp4')}`;
    const outputFilePath = `${this.context?.cacheDir}/${compressedName}`;

    const file = fs.openSync(outputFilePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
    this.avTranscoder.fdDst = file.fd;

    // 5. 准备 & 启动
    await this.avTranscoder.prepare(cfg);
    this.currentProgress = 0;
    await this.avTranscoder.start();
    const stat = fs.statSync(file.fd);
    fileSizeMB = stat.size / (1024 * 1024);
    console.log(`转码后文件的大小为${fileSizeMB}MB`)

    // 6. 返回路径,供调用方上传
    return outputFilePath;
  }

对于异步转码,大家可以参考官方文档:

developer.huawei.com/consumer/cn…

主要增加了worker线程的创建,销毁以及worker进程与宿主进程之间的通信

(对于并发的实现,除了worker还可以使用TaskPool,感兴趣的同学可以看看官方文档的介绍和使用指南:developer.huawei.com/consumer/cn…

获取视频缩略图

developer.huawei.com/consumer/cn…

  async getCover(fd: media.AVFileDescriptor): Promise<image.PixelMap>{
    //获取视频缩略图
    let avImageGenerator: media.AVImageGenerator = await media.createAVImageGenerator();
    avImageGenerator.fdSrc = fd
    // pixelMap对象声明,用于图片显示。
    let pixelMap: image.PixelMap | undefined = undefined;

    // 初始化入参。
    let timeUs = 0; // 需要获取的缩略图在视频中的时间点。
    let queryOption = media.AVImageQueryOptions.AV_IMAGE_QUERY_NEXT_SYNC; // AV_IMAGE_QUERY_NEXT_SYNC表示选取传入时间点或之后的关键帧。
    // 输出缩略图的格式参数。
    let param: media.PixelMapParams = {
      width: 300, // 输出的缩略图宽度。
      height: 300 // 输出的缩略图高度。
    };

    // 获取缩略图(promise模式)。
    let vPlayer = await media.createAVPlayer();
    pixelMap = await avImageGenerator.fetchFrameByTime(timeUs, queryOption, param);
    return pixelMap
  }

  //将缩略图写入沙箱中的临时文件--便于上传到后端或者存储桶
    //将缩略图写进临时文件便于获取路径上传到阿里云OSS(考虑到兼容性,不使用buffer直接使用沙箱中的临时文件路径)
  async copyCoverToCache(pixelMap: image.PixelMap, context: common.UIAbilityContext) {
    /* 编码并写入沙箱临时文件 */
    const packer = image.createImagePacker();
    const buffer = await packer.packing(pixelMap, { format: 'image/jpeg', quality: 90 });

    const tmpPath = context.cacheDir + '/context_' + Date.now() + '.jpg';
    const fd = fileIo.openSync(tmpPath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY);
    fs.writeSync(fd.fd, buffer);
    fs.closeSync(fd);
  }

VIdeo组件可以使用previewUri属性设置缩略图

  Video({
    src: media.uri,
    previewUri: media.pixelMap
  })

注意:获取缩略图和转码不可以使用同一个fd实例—HarmonyOS 的音视频框架对同一个 fd 是「独占式」的

不同AVMetadataExtractor或者AVImageGenerator实例,如果需要操作同一资源,需要多次打开文件描述符,不要共用同一文件描述符。(media.AVFileDescriptor) 即不要这样写:

  
interface VideoInfo{
  sandBoxPath: string;
  cover:image.PixelMap
}
  
  async startTranscoderingProcess(uri: string): Promise<VideoInfo> {
    if (!canIUse('SystemCapability.Multimedia.Media.AVTranscoder')) {
      return Promise.reject('AVTranscoder unavailable');
    }

    // 1. 释放旧实例
    if (this.avTranscoder) {
      await this.avTranscoder.release();
    }

    // 2. 创建新实例
    this.avTranscoder = await media.createAVTranscoder();
    this.setAVTranscoderCallback();

    let srcFd: number | null = null;
    let fileSizeMB: number | null = null;
    if (uri.includes('://')) {
      const srcFile = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
      srcFd = srcFile.fd;
      const stat = fs.statSync(srcFd);
      fileSizeMB = stat.size / (1024 * 1024);
    } else {
      const srcFile = fs.openSync(uri, fs.OpenMode.READ_ONLY);
      srcFd = srcFile.fd;
      const stat = fs.statSync(srcFd);
      fileSizeMB = stat.size / (1024 * 1024);
    }
    console.log(`转码前文件的大小为${fileSizeMB}MB`)
    // 3. 输入 fd
    // const fdSrc = await this.context?.resourceManager.getRawFd(uri);
    // this.avTranscoder.fdSrc = fdSrc!;
    const fd: media.AVFileDescriptor = { fd: srcFd!, offset: 0, length: -1 }
    this.avTranscoder.fdSrc = fd; //包装成AVFileDescriptor

    // 4. 输出文件路径 & fd
    const fileName = uri.split('/').pop() || `compressed_${Date.now()}.mp4`;
    const compressedName = `compressed_${Date.now()}_${fileName.replace(/\.[^.]+$/, '.mp4')}`;
    const outputFilePath = `${this.context?.cacheDir}/${compressedName}`;

    const file = fs.openSync(outputFilePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
    this.avTranscoder.fdDst = file.fd;

    // 5. 准备 & 启动
    await this.avTranscoder.prepare(this.avConfig);
    this.currentProgress = 0;
    await this.avTranscoder.start();
    const stat = fs.statSync(file.fd);
    fileSizeMB = stat.size / (1024 * 1024);
    console.log(`转码后文件的大小为${fileSizeMB}MB`)
    
    //获取缩略图
    const coverImg:iamge.PixelMap = this.getCover(fd)
    
    // 6. 返回路径,供调用方上传
    return {sandBoxPath:outputFilePath,cover:coverImg};
  }

  
  
   async getCover(fd:media.AVFileDescriptor ): Promise<image.PixelMap> {
    //获取视频缩略图
    let avImageGenerator: media.AVImageGenerator = await media.createAVImageGenerator();
    avImageGenerator.fdSrc = fd
    // pixelMap对象声明,用于图片显示。
    let pixelMap: image.PixelMap | undefined = undefined;

    // 初始化入参。
    let timeUs = 0; // 需要获取的缩略图在视频中的时间点。
    let queryOption = media.AVImageQueryOptions.AV_IMAGE_QUERY_NEXT_SYNC; // AV_IMAGE_QUERY_NEXT_SYNC表示选取传入时间点或之后的关键帧。
    // 输出缩略图的格式参数。
    let param: media.PixelMapParams = {
      width: 300, // 输出的缩略图宽度。
      height: 300 // 输出的缩略图高度。
    };

    pixelMap = await avImageGenerator.fetchFrameByTime(timeUs, queryOption, param);
    // 释放资源(promise模式)。
    avImageGenerator.release();
    return pixelMap
  }

完整工具类封装:

import { media } from '@kit.MediaKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { common } from '@kit.AbilityKit';
import fs, { ReadOptions } from '@ohos.file.fs';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import fileIo from '@ohos.file.fs';
import { image } from '@kit.ImageKit';
import fileIO from '@ohos.fileio';

export interface VideoTrackInfo { // ④ 显式声明接口,替代 Object literal
  width: number;
  height: number;
  bitrate: number;
  frameRate: number;
}

/* 可调整的“输出模板” */
const TARGET_WIDTH = 1280;
const TARGET_HEIGHT = 720;
const TARGET_FPS = 30;

/* H.264 中等动态内容 baseline(截图表格) */
const BASELINE_MBPS: Record<string, number> = {
  '720p': 3, // 2.5-4 Mbps 取中值
  '1080p': 5, // 4-6 Mbps
  '4K': 20, // 15-25 Mbps
};

/* 分辨率档次判断 */
function getResolutionLevel(w: number, h: number): string {
  if (w >= 3840 || h >= 2160) {
    return '4K';
  }
  if (w >= 1920 || h >= 1080) {
    return '1080p';
  }
  return '720p';
}

/* clip 工具 */
function clip(min: number, max: number, v: number): number {
  return Math.max(min, Math.min(max, v));
}

export class AVTranscoderDemo {
  private avTranscoder: media.AVTranscoder | undefined = undefined;
  private context: Context | undefined;
  private currentProgress: number = 0;

  constructor(context: Context | undefined) {
    if (context != undefined) {
      this.context = context;
    }
  }
  // 注册avTranscoder回调函数。
  setAVTranscoderCallback() {
    if (canIUse('SystemCapability.Multimedia.Media.AVTranscoder')) {
      if (this.avTranscoder != undefined) {
        // 转码完成回调函数。
        this.avTranscoder.on('complete', async () => {
          console.log(`AVTranscoder is completed`);
          await this.releaseTranscoderingProcess();
        });
        // 错误上报回调函数。
        this.avTranscoder.on('error', (err: BusinessError) => {
          console.error(`AVTranscoder failed, code is ${err.code}, message is ${err.message}`);
        });
        // 进度上报回调函数
        this.avTranscoder.on('progressUpdate', (progress: number) => {
          console.info(`AVTranscoder progressUpdate = ${progress}`);
          this.currentProgress = progress;
        })
      }
    }
  }

  //  开始转码对应的流程,返回转码后文件路径
  async startTranscoderingProcess(uri: string): Promise<string> {

    const cfg = await this.setConfig(uri);

    // 下面再开始你的 AVTranscoder 流程

    if (!canIUse('SystemCapability.Multimedia.Media.AVTranscoder')) {
      return Promise.reject('AVTranscoder unavailable');
    }

    // 1. 释放旧实例
    if (this.avTranscoder) {
      await this.avTranscoder.release();
    }

    // 2. 创建新实例
    this.avTranscoder = await media.createAVTranscoder();
    this.setAVTranscoderCallback();

    let srcFd: number | null = null;
    let fileSizeMB: number | null = null;
    if (uri.includes('://')) {
      const srcFile = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
      srcFd = srcFile.fd;
      const stat = fs.statSync(srcFd);
      fileSizeMB = stat.size / (1024 * 1024);
    } else {
      const srcFile = fs.openSync(uri, fs.OpenMode.READ_ONLY);
      srcFd = srcFile.fd;
      const stat = fs.statSync(srcFd);
      fileSizeMB = stat.size / (1024 * 1024);
    }
    console.log(`转码前文件的大小为${fileSizeMB}MB`)
    // 3. 输入 fd
    // const fdSrc = await this.context?.resourceManager.getRawFd(uri);
    // this.avTranscoder.fdSrc = fdSrc!;
    const fd: media.AVFileDescriptor = { fd: srcFd!, offset: 0, length: -1 }
    this.avTranscoder.fdSrc = fd; //包装成AVFileDescriptor

    // 4. 输出文件路径 & fd
    const fileName = uri.split('/').pop() || `compressed_${Date.now()}.mp4`;
    const compressedName = `compressed_${Date.now()}_${fileName.replace(/\.[^.]+$/, '.mp4')}`;
    const outputFilePath = `${this.context?.cacheDir}/${compressedName}`;

    const file = fs.openSync(outputFilePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
    this.avTranscoder.fdDst = file.fd;

    // 5. 准备 & 启动
    await this.avTranscoder.prepare(cfg);
    this.currentProgress = 0;
    await this.avTranscoder.start();
    const stat = fs.statSync(file.fd);
    fileSizeMB = stat.size / (1024 * 1024);
    console.log(`转码后文件的大小为${fileSizeMB}MB`)

    // 6. 返回路径,供调用方上传
    return outputFilePath;
  }

  /**
   * 传入本地文件路径,返回视频主轨信息
   */
  async getVideoTrack(uri: string): Promise<VideoTrackInfo> {
    console.info('获取视频详细信息');
    let srcFd = -1;
    let player: media.AVPlayer | null = null;

    try {
      /* 1. 打开文件 */
      const srcFile = uri.includes('://')
        ? fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY)
        : fs.openSync(uri, fs.OpenMode.READ_ONLY);
      srcFd = srcFile.fd;

      /* 2. 创建播放器 */
      player = await media.createAVPlayer();
      player.fdSrc = { fd: srcFd };

      /* 3. 等「initialized」→ 再 prepare → 再等「prepared」 */
      await new Promise<void>((res, rej) => {
        player!.on('stateChange', (s: string) => {
          if (s === 'initialized') {
            player!.prepare(); // 只有 initialized 才能调
          }
          if (s === 'prepared') {
            res(); // prepared 完成才能往下走
          }
          if (s === 'error') {
            rej(new Error('prepare fail'));
          }
        });
      });

      /* 4. 现在状态 = prepared,可以安全读轨道 */
      const tracks = await player.getTrackDescription();
      for (const t of tracks) {
        if (t[media.MediaDescriptionKey.MD_KEY_TRACK_TYPE] === media.MediaType.MEDIA_TYPE_VID) {
          const width = t[media.MediaDescriptionKey.MD_KEY_WIDTH] as number;
          const height = t[media.MediaDescriptionKey.MD_KEY_HEIGHT] as number;
          const bitrate = t[media.MediaDescriptionKey.MD_KEY_BITRATE] as number;
          const frameRate = t[media.MediaDescriptionKey.MD_KEY_FRAME_RATE] as number;
          console.info(`视频轨 ${width}×${height} 码率=${bitrate} bps 帧率=${frameRate / 100} fps`);
          return {
            width,
            height,
            bitrate,
            frameRate: frameRate / 100
          };
        }
      }
      throw new Error('无视频轨');
    } finally {
      if (player) {
        await player.release();
      }
      if (srcFd !== -1) {
        fileIo.closeSync(srcFd);
      }
    }
  }

  async setConfig(uri: string): Promise<media.AVTranscoderConfig> {
    const src = await this.getVideoTrack(uri); // 宽高、码率、帧率

    /* 1. 系数计算(截图公式) */
    const fpsFactor = clip(0.5, 2.0, (TARGET_FPS + src.frameRate) / (2 * src.frameRate));
    const resolutionFactor = (TARGET_WIDTH * TARGET_HEIGHT) / (src.width * src.height);
    const estimatedBitrate = fpsFactor * resolutionFactor * src.bitrate; // bps

    /* 2. baseline 上限 */
    const level = getResolutionLevel(TARGET_WIDTH, TARGET_HEIGHT);
    const baselineBitrate = BASELINE_MBPS[level] * 1_000_000; // bps

    /* 3. 取二者较小值,确保比源视频低 */
    const targetBitrate = Math.min(estimatedBitrate, baselineBitrate, src.bitrate);

    /* 4. 帧率同理不超过源视频 */
    const targetFrameRate = Math.min(TARGET_FPS, src.frameRate);

    console.info(`转码目标 ${TARGET_WIDTH}×${TARGET_HEIGHT} @${targetFrameRate} fps`);
    console.info(`目标码率 ${(targetBitrate / 1_000_000).toFixed(2)} Mbps`);
    
    /* 5. 返回 AVTranscoderConfig */
    return {
      fileFormat: media.ContainerFormatType.CFT_MPEG_4, // mp4
      videoCodec: media.CodecMimeType.VIDEO_AVC, // H.264
      audioCodec: media.CodecMimeType.AUDIO_AAC, // AAC
      videoFrameWidth: TARGET_WIDTH,
      videoFrameHeight: TARGET_HEIGHT,
      videoBitrate: targetBitrate,
      audioBitrate: 100000, // 128 kbps 默认值,按需改
    };
  }

  async getCover(uri: string): Promise<image.PixelMap> {
    //获取文件描述符
    let srcFd: number | null = null;
    if (uri.includes('://')) {
      const srcFile = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
      srcFd = srcFile.fd;
    } else {
      const srcFile = fs.openSync(uri, fs.OpenMode.READ_ONLY);
      srcFd = srcFile.fd;
    }

    const fd: media.AVFileDescriptor = { fd: srcFd!, offset: 0, length: -1 }
    //获取视频缩略图
    let avImageGenerator: media.AVImageGenerator = await media.createAVImageGenerator();
    avImageGenerator.fdSrc = fd
    // pixelMap对象声明,用于图片显示。
    let pixelMap: image.PixelMap | undefined = undefined;

    // 初始化入参。
    let timeUs = 0; // 需要获取的缩略图在视频中的时间点。
    let queryOption = media.AVImageQueryOptions.AV_IMAGE_QUERY_NEXT_SYNC; // AV_IMAGE_QUERY_NEXT_SYNC表示选取传入时间点或之后的关键帧。
    // 输出缩略图的格式参数。
    let param: media.PixelMapParams = {
      width: 300, // 输出的缩略图宽度。
      height: 300 // 输出的缩略图高度。
    };

    pixelMap = await avImageGenerator.fetchFrameByTime(timeUs, queryOption, param);
    // 释放资源(promise模式)。
    avImageGenerator.release();
    return pixelMap
  }

  // 暂停转码对应的流程。
  async pauseTranscoderingProcess() {
    if (canIUse('SystemCapability.Multimedia.Media.AVTranscoder')) {
      if (this.avTranscoder != undefined) { // 仅在调用start返回后调用pause为合理调用。
        await this.avTranscoder.pause();
      }
    }
  }

  // 恢复对应的转码流程。
  async resumeTranscoderingProcess() {
    if (canIUse('SystemCapability.Multimedia.Media.AVTranscoder')) {
      if (this.avTranscoder != undefined) { // 仅在调用pause返回后调用resume为合理调用。
        await this.avTranscoder.resume();
      }
    }
  }

  // 释放转码流程。
  async releaseTranscoderingProcess() {
    if (canIUse('SystemCapability.Multimedia.Media.AVTranscoder')) {
      if (this.avTranscoder != undefined) {
        // 1.释放转码实例。
        await this.avTranscoder.release();
        this.avTranscoder = undefined;
        // 2.关闭转码目标文件fd。
        fs.closeSync(this.avTranscoder!.fdDst);
      }
    }
  }

  // 获取当前进度
  getCurrentProgress(): number {
    console.info(`getCurrentProgress = ${this.currentProgress}`);
    return this.currentProgress;
  }

}

这一集就到这里,如果有任何疑问或者有不足之处需要纠正,欢迎大家畅所欲言。下一个最终篇我们聊聊图片、视频文件的上传那些事。