HarmonyOS开发视频转码:格式转换、分辨率调整与码率控制全链路实战

举报
Jack20 发表于 2026/06/20 21:14:40 2026/06/20
【摘要】 HarmonyOS开发中的视频转码:格式转换、分辨率调整与码率控制全链路实战核心要点:掌握HarmonyOS视频转码的完整流程,理解"解码→处理→编码"的转码管线,学会格式转换、分辨率缩放、码率控制,以及转码性能优化策略。 一、背景与动机你有没有遇到过这种情况——从手机导出的视频在电脑上播不了?或者一段4K视频太大,想压缩成1080p方便分享?又或者你想把MOV格式转成MP4,以便在网页上...

HarmonyOS开发中的视频转码:格式转换、分辨率调整与码率控制全链路实战

核心要点:掌握HarmonyOS视频转码的完整流程,理解"解码→处理→编码"的转码管线,学会格式转换、分辨率缩放、码率控制,以及转码性能优化策略。


一、背景与动机

你有没有遇到过这种情况——从手机导出的视频在电脑上播不了?或者一段4K视频太大,想压缩成1080p方便分享?又或者你想把MOV格式转成MP4,以便在网页上播放?

这些需求,都指向同一个技术:视频转码

转码,说白了就是"翻译"——把一种编码格式、分辨率、码率的视频,"翻译"成另一种。这个过程需要先解码原始视频,对帧数据做必要的处理(缩放、裁剪等),然后重新编码输出。

转码是视频处理中"最重"的操作——它同时涉及解码和编码,计算量是单独播放或录制的两倍以上。所以,转码的性能优化至关重要。

今天这篇文章,我们就来把视频转码这件事从头到尾讲透。


二、核心原理

2.1 视频转码的完整流程

视频转码的核心流程是**“解码→处理→编码”**三段式:

flowchart TB
    classDef primary fill:#4FC3F7,stroke:#0288D1,color:#000
    classDef warning fill:#FFB74D,stroke:#F57C00,color:#000
    classDef error fill:#EF5350,stroke:#C62828,color:#fff
    classDef info fill:#81C784,stroke:#388E3C,color:#000
    classDef purple fill:#CE93D8,stroke:#7B1FA2,color:#000

    A[原始视频文件]:::primary --> B[解封装 Demux]:::warning
    B --> C[视频解码 Decode]:::error
    B --> D[音频解码 Decode]:::error
    
    C --> E[帧处理 Process]:::info
    E --> E1[分辨率缩放]:::purple
    E --> E2[裁剪]:::purple
    E --> E3[效果滤镜]:::purple
    
    E1 --> F[视频编码 Encode]:::error
    E2 --> F
    E3 --> F
    D --> G[音频编码 Encode]:::error
    
    F --> H[封装 Mux]:::warning
    G --> H
    H --> I[输出视频文件]:::primary

2.2 转码类型

转码类型 说明 典型场景
格式转换 改变编码格式(如H.264→H.265) 兼容性适配
分辨率调整 改变画面尺寸(如4K→1080p) 存储压缩、适配不同设备
码率调整 改变压缩质量(如8Mbps→2Mbps) 带宽适配、文件压缩
帧率调整 改变帧率(如60fps→30fps) 兼容性、文件压缩
容器转换 改变封装格式(如MOV→MP4) 平台兼容性

2.3 码率控制策略

码率控制是转码中最关键的参数,直接影响视频质量和文件大小:

flowchart LR
    classDef primary fill:#4FC3F7,stroke:#0288D1,color:#000
    classDef warning fill:#FFB74D,stroke:#F57C00,color:#000
    classDef error fill:#EF5350,stroke:#C62828,color:#fff
    classDef info fill:#81C784,stroke:#388E3C,color:#000
    classDef purple fill:#CE93D8,stroke:#7B1FA2,color:#000

    A[码率控制模式] --> B[CBR 固定码率]:::primary
    A --> C[VBR 可变码率]:::warning
    A --> D[ABR 平均码率]:::info
    A --> E[CQP 恒定质量]:::purple
    
    B --> B1[每帧码率恒定]:::primary
    B --> B2[适合直播推流]:::primary
    
    C --> C1[复杂场景高码率]:::warning
    C --> C2[简单场景低码率]:::warning
    C --> C3[适合存储]:::warning
    
    D --> D1[码率波动但均值固定]:::info
    D --> D2[折中方案]:::info
    
    E --> E1[固定QP]:::purple
    E --> E2[质量最稳定]:::purple

2.4 分辨率缩放算法

分辨率调整时,需要选择合适的缩放算法:

算法 质量 速度 适用场景
最近邻 最低 最快 缩略图生成
双线性 中等 通用缩放
双三次 较高 中等 高质量缩放
Lanczos 最高 最慢 专业视频处理

三、代码实战

3.1 基础实战:视频格式转换

最基础的转码操作——将视频从一种编码格式转换为另一种:

// VideoTranscodeDemo.ets
// 视频格式转换基础示例

import { media } from '@kit.MediaKit'
import { fileIo as fs } from '@kit.CoreFileKit'
import { common } from '@kit.AbilityKit'

// 转码配置
export interface TranscodeConfig {
  // 输入配置
  inputFilePath: string
  // 输出配置
  outputFilePath: string
  outputCodec: media.CodecMimeType   // 输出编码格式
  outputWidth: number                 // 输出宽度
  outputHeight: number                // 输出高度
  outputBitrate: number               // 输出码率(bps)
  outputFrameRate: number             // 输出帧率
  outputFormat: media.ContainerFormatType // 输出容器格式
}

// 转码进度回调
export interface TranscodeCallbacks {
  onProgress?: (percent: number) => void
  onComplete?: (outputPath: string) => void
  onError?: (error: string) => void
}

// 转码管理器
export class VideoTranscoder {
  private avPlayer: media.AVPlayer | null = null
  private avRecorder: media.AVRecorder | null = null
  private callbacks: TranscodeCallbacks = {}
  private isTranscoding: boolean = false
  private totalDuration: number = 0

  // 设置回调
  setCallbacks(callbacks: TranscodeCallbacks) {
    this.callbacks = callbacks
  }

  // 获取视频信息
  async getVideoInfo(filePath: string): Promise<{
    width: number; height: number; duration: number;
    bitrate: number; frameRate: number; codec: string
  } | null> {
    try {
      const player = await media.createAVPlayer()
      
      return new Promise((resolve) => {
        player.on('stateChange', async (state: string) => {
          if (state === 'initialized') {
            await player.prepare()
          }
          if (state === 'prepared') {
            const info = {
              width: 0, height: 0, duration: 0,
              bitrate: 0, frameRate: 0, codec: ''
            }
            
            // 获取视频轨道信息
            const trackInfo = await player.getTrackDescription()
            for (const track of trackInfo) {
              if (track.trackType === media.MediaType.MEDIA_TYPE_VIDEO) {
                info.width = track.width || 0
                info.height = track.height || 0
                info.frameRate = track.frameRate || 30
                info.bitrate = track.bitrate || 0
                info.codec = track.mimeType || ''
              }
            }
            
            info.duration = player.duration || 0
            
            await player.release()
            resolve(info)
          }
        })
        
        player.url = filePath
      })
    } catch (err) {
      console.error(`[Transcoder] 获取视频信息失败: ${err}`)
      return null
    }
  }

  // 执行转码
  async transcode(config: TranscodeConfig): Promise<void> {
    if (this.isTranscoding) {
      this.callbacks.onError?.('正在转码中,请等待完成')
      return
    }

    this.isTranscoding = true

    try {
      // 第一步:创建AVPlayer用于解码
      this.avPlayer = await media.createAVPlayer()
      
      // 第二步:创建AVRecorder用于编码
      this.avRecorder = await media.createAVRecorder()
      
      // 第三步:打开输出文件
      const outputFile = fs.openSync(
        config.outputFilePath,
        fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE
      )

      // 第四步:配置AVRecorder
      const recorderConfig: media.AVRecorderConfig = {
        audioSourceType: media.AudioSourceType.AUDIO_SOURCE_TYPE_MIC,
        videoSourceType: media.VideoSourceType.VIDEO_SOURCE_TYPE_SURFACE_YUV,
        profile: {
          audioBitrate: 128000,
          audioChannels: 2,
          audioCodec: media.CodecMimeType.AUDIO_AAC,
          audioSampleRate: 44100,
          fileFormat: config.outputFormat,
          videoBitrate: config.outputBitrate,
          videoCodec: config.outputCodec,
          videoFrameWidth: config.outputWidth,
          videoFrameHeight: config.outputHeight,
          videoFrameRate: config.outputFrameRate
        },
        url: `fd://${outputFile.fd}`
      }

      // 第五步:监听AVPlayer状态
      this.avPlayer.on('stateChange', async (state: string) => {
        console.info(`[Transcoder] Player状态: ${state}`)
        
        if (state === 'initialized') {
          // 获取录制器Surface
          const surfaceId = await this.avRecorder!.getInputSurface()
          this.avPlayer!.surfaceId = surfaceId
          await this.avPlayer!.prepare()
        }
        
        if (state === 'prepared') {
          this.totalDuration = this.avPlayer!.duration || 0
          // 准备录制器
          await this.avRecorder!.prepare(recorderConfig)
          // 开始播放和录制
          await this.avPlayer!.play()
          await this.avRecorder!.start()
        }
        
        if (state === 'completed') {
          // 播放完成,停止录制
          await this.avRecorder!.stop()
          await this.avRecorder!.release()
          await this.avPlayer!.release()
          
          this.isTranscoding = false
          this.callbacks.onComplete?.(config.outputFilePath)
        }
      })

      // 监听播放进度
      this.avPlayer.on('timeUpdate', (time: number) => {
        if (this.totalDuration > 0) {
          const percent = Math.round(time / this.totalDuration * 100)
          this.callbacks.onProgress?.(percent)
        }
      })

      // 监听错误
      this.avPlayer.on('error', (err) => {
        this.isTranscoding = false
        this.callbacks.onError?.(`播放错误: ${err.message}`)
      })

      this.avRecorder.on('error', (err) => {
        this.isTranscoding = false
        this.callbacks.onError?.(`录制错误: ${err.message}`)
      })

      // 第六步:设置数据源,开始转码
      this.avPlayer.url = config.inputFilePath

    } catch (err) {
      this.isTranscoding = false
      this.callbacks.onError?.(`转码失败: ${err}`)
      console.error(`[Transcoder] 转码失败: ${err}`)
    }
  }

  // 取消转码
  async cancelTranscode() {
    try {
      if (this.avRecorder) {
        await this.avRecorder.stop()
        await this.avRecorder.release()
      }
      if (this.avPlayer) {
        await this.avPlayer.stop()
        await this.avPlayer.release()
      }
    } catch (err) {
      console.error(`[Transcoder] 取消转码失败: ${err}`)
    }
    
    this.isTranscoding = false
    this.avPlayer = null
    this.avRecorder = null
  }
}

3.2 进阶实战:分辨率调整与码率控制

在实际项目中,转码往往需要同时调整分辨率和码率,并支持多种预设方案:

// VideoCompressDemo.ets
// 视频压缩与分辨率调整

import { media } from '@kit.MediaKit'
import { fileIo as fs } from '@kit.CoreFileKit'
import { common } from '@kit.AbilityKit'

// 压缩质量预设
export enum CompressQuality {
  ORIGINAL = 'original',     // 原始质量
  HIGH = 'high',             // 高质量
  MEDIUM = 'medium',         // 中等质量
  LOW = 'low',               // 低质量
  VERY_LOW = 'very_low'      // 极低质量(聊天发送)
}

// 压缩预设配置
export interface CompressPreset {
  name: string
  maxWidth: number
  maxHeight: number
  bitrate: number
  frameRate: number
  codec: media.CodecMimeType
}

// 压缩预设表
export const COMPRESS_PRESETS: Record<CompressQuality, CompressPreset> = {
  [CompressQuality.ORIGINAL]: {
    name: '原始质量',
    maxWidth: 3840, maxHeight: 2160,
    bitrate: 8000000, frameRate: 30,
    codec: media.CodecMimeType.VIDEO_HEVC
  },
  [CompressQuality.HIGH]: {
    name: '高质量',
    maxWidth: 1920, maxHeight: 1080,
    bitrate: 4000000, frameRate: 30,
    codec: media.CodecMimeType.VIDEO_AVC
  },
  [CompressQuality.MEDIUM]: {
    name: '中等质量',
    maxWidth: 1280, maxHeight: 720,
    bitrate: 2000000, frameRate: 30,
    codec: media.CodecMimeType.VIDEO_AVC
  },
  [CompressQuality.LOW]: {
    name: '低质量',
    maxWidth: 854, maxHeight: 480,
    bitrate: 1000000, frameRate: 24,
    codec: media.CodecMimeType.VIDEO_AVC
  },
  [CompressQuality.VERY_LOW]: {
    name: '极低质量',
    maxWidth: 640, maxHeight: 360,
    bitrate: 500000, frameRate: 20,
    codec: media.CodecMimeType.VIDEO_AVC
  }
}

// 计算目标分辨率(保持宽高比)
export function calculateTargetResolution(
  srcWidth: number,
  srcHeight: number,
  maxWidth: number,
  maxHeight: number
): { width: number; height: number } {
  // 如果源分辨率已经小于目标,不需要缩放
  if (srcWidth <= maxWidth && srcHeight <= maxHeight) {
    return { width: srcWidth, height: srcHeight }
  }

  // 计算缩放比例,取宽高中较小的比例
  const scaleW = maxWidth / srcWidth
  const scaleH = maxHeight / srcHeight
  const scale = Math.min(scaleW, scaleH)

  // 确保宽高是偶数(编码器要求)
  let targetWidth = Math.round(srcWidth * scale)
  let targetHeight = Math.round(srcHeight * scale)
  
  // 宽高必须是2的倍数
  targetWidth = targetWidth % 2 === 0 ? targetWidth : targetWidth - 1
  targetHeight = targetHeight % 2 === 0 ? targetHeight : targetHeight - 1

  return { width: targetWidth, height: targetHeight }
}

// 根据分辨率计算推荐码率
export function calculateBitrate(
  width: number,
  height: number,
  frameRate: number,
  quality: CompressQuality
): number {
  const pixels = width * height
  
  // 基础码率系数(每像素每帧的比特数)
  let bpp: number
  switch (quality) {
    case CompressQuality.ORIGINAL:
      bpp = 0.2
      break
    case CompressQuality.HIGH:
      bpp = 0.12
      break
    case CompressQuality.MEDIUM:
      bpp = 0.08
      break
    case CompressQuality.LOW:
      bpp = 0.05
      break
    case CompressQuality.VERY_LOW:
      bpp = 0.03
      break
  }

  return Math.round(pixels * frameRate * bpp)
}

// ====== 视频压缩UI ======

@Entry
@Component
struct VideoCompressDemo {
  @State selectedQuality: CompressQuality = CompressQuality.MEDIUM
  @State sourceInfo: { width: number; height: number; size: number; duration: number } = {
    width: 1920, height: 1080, size: 52428800, duration: 60000
  }
  @State targetResolution: { width: number; height: number } = { width: 1280, height: 720 }
  @State targetBitrate: number = 2000000
  @State estimatedSize: number = 0
  @State compressProgress: number = 0
  @State isCompressing: boolean = false
  @State statusMessage: string = '选择压缩质量开始压缩'

  // 质量选项
  private qualityOptions: { key: CompressQuality; name: string; desc: string }[] = [
    { key: CompressQuality.ORIGINAL, name: '原始', desc: '不压缩' },
    { key: CompressQuality.HIGH, name: '高质量', desc: '1080p' },
    { key: CompressQuality.MEDIUM, name: '中等', desc: '720p' },
    { key: CompressQuality.LOW, name: '低质量', desc: '480p' },
    { key: CompressQuality.VERY_LOW, name: '极低', desc: '360p' }
  ]

  aboutToAppear() {
    this.updateTargetConfig()
  }

  // 更新目标配置
  private updateTargetConfig() {
    const preset = COMPRESS_PRESETS[this.selectedQuality]
    
    this.targetResolution = calculateTargetResolution(
      this.sourceInfo.width,
      this.sourceInfo.height,
      preset.maxWidth,
      preset.maxHeight
    )
    
    this.targetBitrate = calculateBitrate(
      this.targetResolution.width,
      this.targetResolution.height,
      preset.frameRate,
      this.selectedQuality
    )

    // 估算输出文件大小
    const durationSeconds = this.sourceInfo.duration / 1000
    this.estimatedSize = Math.round(this.targetBitrate * durationSeconds / 8)
  }

  // 格式化文件大小
  private formatSize(bytes: number): string {
    if (bytes < 1024) return `${bytes}B`
    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
    if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
    return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`
  }

  build() {
    Scroll() {
      Column() {
        // 标题
        Text('视频压缩工具')
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
          .fontColor('#ffffff')
          .margin({ bottom: 20 })

        // 源视频信息
        Column() {
          Text('源视频信息')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4FC3F7')
            .margin({ bottom: 8 })

          Row() {
            Text('分辨率:')
              .fontSize(14)
              .fontColor('#aaaaaa')
            Text(`${this.sourceInfo.width}×${this.sourceInfo.height}`)
              .fontSize(14)
              .fontColor('#ffffff')
          }
          .width('100%')
          .margin({ bottom: 4 })

          Row() {
            Text('文件大小:')
              .fontSize(14)
              .fontColor('#aaaaaa')
            Text(this.formatSize(this.sourceInfo.size))
              .fontSize(14)
              .fontColor('#ffffff')
          }
          .width('100%')
          .margin({ bottom: 4 })

          Row() {
            Text('时长:')
              .fontSize(14)
              .fontColor('#aaaaaa')
            Text(`${Math.round(this.sourceInfo.duration / 1000)}`)
              .fontSize(14)
              .fontColor('#ffffff')
          }
          .width('100%')
        }
        .width('90%')
        .padding(16)
        .borderRadius(12)
        .backgroundColor('rgba(79, 195, 247, 0.1)')
        .border({ width: 1, color: 'rgba(79, 195, 247, 0.3)' })

        // 压缩质量选择
        Text('压缩质量')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#ffffff')
          .margin({ top: 20, bottom: 8 })

        Column() {
          ForEach(this.qualityOptions, (option: { key: CompressQuality; name: string; desc: string }) => {
            Row() {
              Radio({ value: option.key, group: 'quality' })
                .checked(this.selectedQuality === option.key)
                .onChange((isChecked: boolean) => {
                  if (isChecked) {
                    this.selectedQuality = option.key
                    this.updateTargetConfig()
                  }
                })

              Column() {
                Text(option.name)
                  .fontSize(14)
                  .fontColor('#ffffff')
                Text(option.desc)
                  .fontSize(12)
                  .fontColor('#888888')
              }
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 12 })
              .layoutWeight(1)

              // 显示该质量下的配置
              if (this.selectedQuality === option.key) {
                Text('✓')
                  .fontSize(18)
                  .fontColor('#4FC3F7')
              }
            }
            .width('100%')
            .padding(12)
            .borderRadius(8)
            .backgroundColor(this.selectedQuality === option.key ? 'rgba(79,195,247,0.15)' : 'transparent')
          })
        }
        .width('90%')

        // 目标配置预览
        Column() {
          Text('输出配置预览')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#81C784')
            .margin({ bottom: 8 })

          Row() {
            Text('目标分辨率:')
              .fontSize(14)
              .fontColor('#aaaaaa')
            Text(`${this.targetResolution.width}×${this.targetResolution.height}`)
              .fontSize(14)
              .fontColor('#ffffff')
              .fontWeight(FontWeight.Bold)
          }
          .width('100%')
          .margin({ bottom: 4 })

          Row() {
            Text('目标码率:')
              .fontSize(14)
              .fontColor('#aaaaaa')
            Text(`${(this.targetBitrate / 1000).toFixed(0)} kbps`)
              .fontSize(14)
              .fontColor('#ffffff')
              .fontWeight(FontWeight.Bold)
          }
          .width('100%')
          .margin({ bottom: 4 })

          Row() {
            Text('预估文件大小:')
              .fontSize(14)
              .fontColor('#aaaaaa')
            Text(this.formatSize(this.estimatedSize))
              .fontSize(14)
              .fontColor('#ffffff')
              .fontWeight(FontWeight.Bold)
          }
          .width('100%')
          .margin({ bottom: 4 })

          // 压缩比
          Row() {
            Text('压缩比:')
              .fontSize(14)
              .fontColor('#aaaaaa')
            Text(`${Math.round((1 - this.estimatedSize / this.sourceInfo.size) * 100)}%`)
              .fontSize(14)
              .fontColor('#81C784')
              .fontWeight(FontWeight.Bold)
          }
          .width('100%')
        }
        .width('90%')
        .padding(16)
        .borderRadius(12)
        .backgroundColor('rgba(129, 199, 132, 0.1)')
        .border({ width: 1, color: 'rgba(129, 199, 132, 0.3)' })
        .margin({ top: 16 })

        // 压缩进度
        if (this.isCompressing) {
          Column() {
            Text('正在压缩...')
              .fontSize(14)
              .fontColor('#ffffff')
              .margin({ bottom: 8 })

            Progress({
              value: this.compressProgress,
              total: 100,
              type: ProgressType.Linear
            })
              .width('100%')
              .color('#4FC3F7')
              .backgroundColor('#333333')

            Text(`${this.compressProgress}%`)
              .fontSize(14)
              .fontColor('#4FC3F7')
              .margin({ top: 4 })
          }
          .width('90%')
          .margin({ top: 16 })
        }

        // 状态消息
        Text(this.statusMessage)
          .fontSize(14)
          .fontColor('#aaaaaa')
          .margin({ top: 12 })

        // 操作按钮
        Row() {
          Button('开始压缩')
            .enabled(!this.isCompressing)
            .backgroundColor('#4CAF50')
            .onClick(() => {
              this.startCompress()
            })

          Button('取消')
            .enabled(this.isCompressing)
            .backgroundColor('#F44336')
            .onClick(() => {
              this.cancelCompress()
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceEvenly)
        .padding(16)
        .margin({ top: 8 })
      }
      .padding(16)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#1a1a2e')
  }

  private startCompress() {
    this.isCompressing = true
    this.compressProgress = 0
    this.statusMessage = '正在压缩...'

    // 模拟压缩进度
    const timer = setInterval(() => {
      this.compressProgress += 2
      if (this.compressProgress >= 100) {
        this.compressProgress = 100
        this.isCompressing = false
        this.statusMessage = '压缩完成!'
        clearInterval(timer)
      }
    }, 100)
  }

  private cancelCompress() {
    this.isCompressing = false
    this.statusMessage = '压缩已取消'
  }
}

3.3 高级实战:转码性能优化与批量处理

在实际生产中,转码往往需要处理大量视频文件,性能优化至关重要:

// TranscodeOptimizer.ets
// 转码性能优化与批量处理

import { media } from '@kit.MediaKit'
import { fileIo as fs } from '@kit.CoreFileKit'
import { common } from '@kit.AbilityKit'

// 转码任务
export interface TranscodeTask {
  id: string
  inputPath: string
  outputPath: string
  config: TranscodeConfig
  status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
  progress: number
  error?: string
  startTime?: number
  endTime?: number
}

// 转码队列管理器
export class TranscodeQueueManager {
  private tasks: TranscodeTask[] = []
  private currentTask: TranscodeTask | null = null
  private maxConcurrent: number = 1 // 最大并发数(移动端建议1)
  private isRunning: boolean = false
  private onTaskUpdate?: (task: TranscodeTask) => void
  private onQueueComplete?: (results: TranscodeTask[]) => void

  // 设置任务更新回调
  setOnTaskUpdate(callback: (task: TranscodeTask) => void) {
    this.onTaskUpdate = callback
  }

  // 设置队列完成回调
  setOnQueueComplete(callback: (results: TranscodeTask[]) => void) {
    this.onQueueComplete = callback
  }

  // 添加转码任务
  addTask(inputPath: string, outputPath: string, config: TranscodeConfig): string {
    const taskId = `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
    
    const task: TranscodeTask = {
      id: taskId,
      inputPath,
      outputPath,
      config,
      status: 'pending',
      progress: 0
    }
    
    this.tasks.push(task)
    return taskId
  }

  // 移除任务
  removeTask(taskId: string) {
    this.tasks = this.tasks.filter(t => t.id !== taskId)
  }

  // 获取所有任务
  getTasks(): TranscodeTask[] {
    return [...this.tasks]
  }

  // 开始处理队列
  async startQueue() {
    if (this.isRunning) return
    this.isRunning = true
    
    const pendingTasks = this.tasks.filter(t => t.status === 'pending')
    
    for (const task of pendingTasks) {
      if (!this.isRunning) break
      
      this.currentTask = task
      task.status = 'running'
      task.startTime = Date.now()
      this.onTaskUpdate?.(task)
      
      try {
        await this.processTask(task)
        task.status = 'completed'
        task.progress = 100
        task.endTime = Date.now()
      } catch (err) {
        task.status = 'failed'
        task.error = `${err}`
        task.endTime = Date.now()
      }
      
      this.onTaskUpdate?.(task)
    }
    
    this.currentTask = null
    this.isRunning = false
    this.onQueueComplete?.(this.tasks)
  }

  // 处理单个任务
  private async processTask(task: TranscodeTask): Promise<void> {
    return new Promise(async (resolve, reject) => {
      try {
        const transcoder = new VideoTranscoder()
        
        transcoder.setCallbacks({
          onProgress: (percent: number) => {
            task.progress = percent
            this.onTaskUpdate?.(task)
          },
          onComplete: () => {
            resolve()
          },
          onError: (error: string) => {
            reject(error)
          }
        })
        
        await transcoder.transcode(task.config)
      } catch (err) {
        reject(err)
      }
    })
  }

  // 暂停队列
  pauseQueue() {
    this.isRunning = false
  }

  // 取消所有任务
  cancelAll() {
    this.isRunning = false
    for (const task of this.tasks) {
      if (task.status === 'running' || task.status === 'pending') {
        task.status = 'cancelled'
      }
    }
  }

  // 获取队列统计
  getStats(): { total: number; completed: number; failed: number; pending: number } {
    return {
      total: this.tasks.length,
      completed: this.tasks.filter(t => t.status === 'completed').length,
      failed: this.tasks.filter(t => t.status === 'failed').length,
      pending: this.tasks.filter(t => t.status === 'pending').length
    }
  }
}

// 转码性能优化工具
export class TranscodePerformanceOptimizer {
  // 根据设备能力推荐最优转码配置
  static getOptimalConfig(
    srcWidth: number,
    srcHeight: number,
    srcCodec: string,
    targetQuality: CompressQuality
  ): {
    codec: media.CodecMimeType
    width: number
    height: number
    bitrate: number
    frameRate: number
    reason: string
  } {
    const preset = COMPRESS_PRESETS[targetQuality]
    const target = calculateTargetResolution(srcWidth, srcHeight, preset.maxWidth, preset.maxHeight)
    
    // 检查是否可以跳过转码
    if (srcWidth <= preset.maxWidth && srcHeight <= preset.maxHeight && 
        srcCodec.includes('AVC') && targetQuality === CompressQuality.HIGH) {
      return {
        codec: media.CodecMimeType.VIDEO_AVC,
        width: srcWidth,
        height: srcHeight,
        bitrate: preset.bitrate,
        frameRate: preset.frameRate,
        reason: '源视频已满足目标质量,仅需调整码率'
      }
    }

    // 选择最优编码格式
    let codec: media.CodecMimeType
    let reason: string
    
    if (target.width * target.height >= 1920 * 1080) {
      // 1080p及以上,如果支持HEVC就用HEVC
      // 实际项目中需要检查设备能力
      codec = media.CodecMimeType.VIDEO_HEVC
      reason = '高分辨率使用H.265编码效率更高'
    } else {
      codec = media.CodecMimeType.VIDEO_AVC
      reason = '低分辨率使用H.264兼容性更好'
    }

    const bitrate = calculateBitrate(target.width, target.height, preset.frameRate, targetQuality)

    return {
      codec,
      width: target.width,
      height: target.height,
      bitrate,
      frameRate: preset.frameRate,
      reason
    }
  }

  // 估算转码耗时
  static estimateTranscodeTime(
    durationMs: number,
    srcWidth: number,
    srcHeight: number,
    targetWidth: number,
    targetHeight: number,
    codec: media.CodecMimeType
  ): number {
    // 基础转码速度:硬件编码大约实时速度的2~5倍
    const baseSpeedFactor = 3
    
    // 分辨率影响因子
    const srcPixels = srcWidth * srcHeight
    const targetPixels = targetWidth * targetHeight
    const resolutionFactor = (srcPixels + targetPixels) / (1920 * 1080 * 2)
    
    // 编码格式影响因子
    const codecFactor = codec === media.CodecMimeType.VIDEO_HEVC ? 1.5 : 1.0
    
    // 估算耗时(毫秒)
    const estimatedMs = (durationMs / baseSpeedFactor) * resolutionFactor * codecFactor
    
    return Math.round(estimatedMs)
  }

  // 格式化耗时
  static formatDuration(ms: number): string {
    if (ms < 1000) return `${ms}毫秒`
    const seconds = Math.round(ms / 1000)
    if (seconds < 60) return `${seconds}`
    const minutes = Math.floor(seconds / 60)
    const remainSeconds = seconds % 60
    return `${minutes}${remainSeconds}`
  }
}

// ====== 批量转码UI ======

@Entry
@Component
struct BatchTranscodeDemo {
  private queueManager: TranscodeQueueManager = new TranscodeQueueManager()
  
  @State tasks: TranscodeTask[] = []
  @State queueStats: { total: number; completed: number; failed: number; pending: number } = {
    total: 0, completed: 0, failed: 0, pending: 0
  }
  @State isQueueRunning: boolean = false
  @State selectedQuality: CompressQuality = CompressQuality.MEDIUM

  aboutToAppear() {
    // 设置回调
    this.queueManager.setOnTaskUpdate((task: TranscodeTask) => {
      this.tasks = this.queueManager.getTasks()
      this.queueStats = this.queueManager.getStats()
    })
    
    this.queueManager.setOnQueueComplete((results: TranscodeTask[]) => {
      this.isQueueRunning = false
      this.tasks = results
      this.queueStats = this.queueManager.getStats()
    })
  }

  // 添加模拟任务
  private addMockTask() {
    const context = getContext(this) as common.UIAbilityContext
    const inputPath = `${context.cacheDir}/input_video.mp4`
    const outputPath = `${context.cacheDir}/output_${Date.now()}.mp4`
    
    const preset = COMPRESS_PRESETS[this.selectedQuality]
    const config: TranscodeConfig = {
      inputFilePath: inputPath,
      outputFilePath: outputPath,
      outputCodec: preset.codec,
      outputWidth: preset.maxWidth,
      outputHeight: preset.maxHeight,
      outputBitrate: preset.bitrate,
      outputFrameRate: preset.frameRate,
      outputFormat: media.ContainerFormatType.CFT_MPEG_4
    }
    
    this.queueManager.addTask(inputPath, outputPath, config)
    this.tasks = this.queueManager.getTasks()
    this.queueStats = this.queueManager.getStats()
  }

  build() {
    Column() {
      // 标题
      Text('批量视频转码')
        .fontSize(22)
        .fontWeight(FontWeight.Bold)
        .fontColor('#ffffff')
        .margin({ bottom: 16 })

      // 队列统计
      Row() {
        Column() {
          Text(`${this.queueStats.total}`)
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4FC3F7')
          Text('总计')
            .fontSize(12)
            .fontColor('#aaaaaa')
        }
        .layoutWeight(1)

        Column() {
          Text(`${this.queueStats.completed}`)
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#81C784')
          Text('完成')
            .fontSize(12)
            .fontColor('#aaaaaa')
        }
        .layoutWeight(1)

        Column() {
          Text(`${this.queueStats.failed}`)
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#EF5350')
          Text('失败')
            .fontSize(12)
            .fontColor('#aaaaaa')
        }
        .layoutWeight(1)

        Column() {
          Text(`${this.queueStats.pending}`)
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFB74D')
          Text('等待')
            .fontSize(12)
            .fontColor('#aaaaaa')
        }
        .layoutWeight(1)
      }
      .width('90%')
      .padding(16)
      .borderRadius(12)
      .backgroundColor('rgba(79, 195, 247, 0.1)')
      .border({ width: 1, color: 'rgba(79, 195, 247, 0.3)' })

      // 操作按钮
      Row() {
        Button('添加任务')
          .backgroundColor('#4FC3F7')
          .onClick(() => this.addMockTask())

        Button(this.isQueueRunning ? '暂停' : '开始转码')
          .backgroundColor(this.isQueueRunning ? '#FF9800' : '#4CAF50')
          .onClick(() => {
            if (this.isQueueRunning) {
              this.queueManager.pauseQueue()
              this.isQueueRunning = false
            } else {
              this.queueManager.startQueue()
              this.isQueueRunning = true
            }
          })

        Button('全部取消')
          .backgroundColor('#F44336')
          .onClick(() => {
            this.queueManager.cancelAll()
            this.isQueueRunning = false
            this.tasks = this.queueManager.getTasks()
            this.queueStats = this.queueManager.getStats()
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceEvenly)
      .padding(16)

      // 任务列表
      List() {
        ForEach(this.tasks, (task: TranscodeTask) => {
          ListItem() {
            Row() {
              // 状态图标
              Text(this.getStatusIcon(task.status))
                .fontSize(20)
                .margin({ right: 12 })

              // 任务信息
              Column() {
                Text(task.id.substring(0, 16) + '...')
                  .fontSize(14)
                  .fontColor('#ffffff')
                  .maxLines(1)
                
                Text(`${task.config.outputWidth}×${task.config.outputHeight} | ${(task.config.outputBitrate / 1000).toFixed(0)}kbps`)
                  .fontSize(12)
                  .fontColor('#888888')
                  .margin({ top: 2 })
                
                // 进度条
                if (task.status === 'running') {
                  Progress({
                    value: task.progress,
                    total: 100,
                    type: ProgressType.Linear
                  })
                    .width('100%')
                    .color('#4FC3F7')
                    .backgroundColor('#333333')
                    .margin({ top: 4 })
                  
                  Text(`${task.progress}%`)
                    .fontSize(12)
                    .fontColor('#4FC3F7')
                }
                
                // 错误信息
                if (task.error) {
                  Text(task.error)
                    .fontSize(12)
                    .fontColor('#EF5350')
                    .maxLines(2)
                }
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              // 耗时
              if (task.startTime && task.endTime) {
                Text(TranscodePerformanceOptimizer.formatDuration(task.endTime - task.startTime))
                  .fontSize(12)
                  .fontColor('#aaaaaa')
              }
            }
            .width('100%')
            .padding(12)
            .borderRadius(8)
            .backgroundColor('#2a2a3e')
          }
        })
      }
      .layoutWeight(1)
      .width('100%')
      .padding({ left: 16, right: 16 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#1a1a2e')
  }

  private getStatusIcon(status: string): string {
    switch (status) {
      case 'pending': return '⏳'
      case 'running': return '🔄'
      case 'completed': return '✅'
      case 'failed': return '❌'
      case 'cancelled': return '🚫'
      default: return '❓'
    }
  }
}

四、踩坑与注意事项

4.1 转码过程中的内存管理

:转码是"解码+编码"双引擎运行,内存占用是单独播放的两倍以上,在低端设备上容易OOM。

  • 控制输出分辨率,避免4K→4K的无意义转码
  • 使用硬件编解码器,内存管理更高效
  • 监控内存使用,超过阈值时暂停转码

4.2 音画同步问题

:转码后出现音画不同步,声音比画面快或慢。

  • 确保解码和编码使用相同的时间戳基准
  • 在AVPlayer和AVRecorder之间正确传递PTS(Presentation Time Stamp)
  • 如果使用手动解码+编码,需要自己管理音视频同步

4.3 转码中断处理

:转码过程中应用被杀或设备重启,已转码的部分数据丢失。

  • 实现断点续转:记录已转码的进度,重启后从断点继续
  • 使用临时文件:转码完成前写入临时文件,完成后重命名为目标文件
  • 定期保存进度:每隔一定帧数保存一次进度
// 断点续转的实现思路
interface TranscodeCheckpoint {
  taskId: string
  lastProcessedTime: number  // 上次处理到的时间位置
  outputFilePath: string
  timestamp: number
}

// 保存检查点
function saveCheckpoint(checkpoint: TranscodeCheckpoint) {
  // 将检查点信息保存到持久化存储
  // 如AppStorage或文件
}

// 恢复检查点
function loadCheckpoint(taskId: string): TranscodeCheckpoint | null {
  // 从持久化存储加载检查点
  return null
}

4.4 格式兼容性

:某些视频格式(如MKV、FLV)的转码输入可能不被支持。

  • 检查输入格式的支持情况,不支持时给出友好提示
  • 优先使用MP4作为输入和输出格式,兼容性最好
  • 对于不支持的格式,可以考虑使用FFmpeg等第三方库(需要自行编译)

4.5 转码速度与质量的平衡

:追求转码速度时降低质量参数,导致输出视频画质明显下降。

  • 使用"两遍编码"(Two-Pass Encoding):第一遍分析视频复杂度,第二遍根据分析结果分配码率,在相同码率下获得更好的画质
  • 合理设置码率:码率不是越低越好,也不是越高越好,需要根据分辨率和帧率计算推荐值
  • 优先调整分辨率而非码率:从4K降到1080p的效果,远比保持4K但降低码率好

五、HarmonyOS 6适配

5.1 API变更

变更项 HarmonyOS 5 HarmonyOS 6
转码API AVPlayer+AVRecorder组合 新增media.Transcoder专用转码API
硬件加速 编解码器硬件加速 新增GPU辅助缩放加速
HDR转码 不支持 新增HDR→SDR色调映射转码
并发转码 单任务 支持多任务并发(设备允许时)
进度回调 基于timeUpdate 新增精确的帧级进度回调

5.2 迁移指南

// HarmonyOS 6 新增的专用转码API
// media.Transcoder(示例,API可能随版本调整)

async function transcodeWithNewAPI(inputPath: string, outputPath: string) {
  // const transcoder = await media.createTranscoder()
  
  // const config: media.TranscoderConfig = {
  //   inputUrl: inputPath,
  //   outputUrl: outputPath,
  //   videoCodec: media.CodecMimeType.VIDEO_AVC,
  //   videoBitrate: 2000000,
  //   videoWidth: 1280,
  //   videoHeight: 720,
  //   videoFrameRate: 30,
  //   audioCodec: media.CodecMimeType.AUDIO_AAC,
  //   audioBitrate: 128000,
  //   audioSampleRate: 44100,
  //   audioChannels: 2,
  //   containerFormat: media.ContainerFormatType.CFT_MPEG_4
  // }
  
  // transcoder.on('progress', (progress: number) => {
  //   console.info(`转码进度: ${progress}%`)
  // })
  
  // transcoder.on('complete', () => {
  //   console.info('转码完成')
  // })
  
  // transcoder.on('error', (err) => {
  //   console.error(`转码错误: ${err}`)
  // })
  
  // await transcoder.start(config)
}

5.3 性能提升

HarmonyOS 6对转码性能做了以下优化:

  • 专用转码管线:不再需要AVPlayer+AVRecorder组合,减少中间环节
  • GPU缩放加速:分辨率调整使用GPU,速度提升3~5倍
  • 智能码率分配:根据场景复杂度自动调整码率分配
  • 内存优化:转码内存峰值降低约30%

六、总结

mindmap
  root((视频转码))
    转码流程
      解封装 → 解码
      帧处理(缩放/裁剪/效果)
      编码 → 封装
    格式转换
      H.264H.265
      MOVMP4
      编码格式选择策略
    分辨率调整
      保持宽高比缩放
      宽高必须是偶数
      缩放算法选择
    码率控制
      CBR 固定码率
      VBR 可变码率
      ABR 平均码率
      CQP 恒定质量
    性能优化
      硬件编解码
      内存管理
      断点续转
      批量队列管理
    关键要点
      音画同步
      格式兼容性
      转码中断处理
      速度与质量平衡

一句话总结:视频转码的核心是"解码→处理→编码"三段式管线,关键在于选择合适的编码格式和码率控制策略,同时做好内存管理、音画同步和异常处理。批量转码时使用队列管理器,确保任务有序执行。

记住三个关键

  1. 先分析再转码——获取源视频信息,计算最优配置,避免无意义的转码
  2. 码率是核心参数——码率决定画质和文件大小的平衡点,根据分辨率和帧率计算推荐值
  3. 异常处理要完备——转码是最容易出错的环节,断点续转、进度保存、错误恢复一个都不能少
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。