HarmonyOS开发视频转码:格式转换、分辨率调整与码率控制全链路实战
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.264 → H.265
MOV → MP4
编码格式选择策略
分辨率调整
保持宽高比缩放
宽高必须是偶数
缩放算法选择
码率控制
CBR 固定码率
VBR 可变码率
ABR 平均码率
CQP 恒定质量
性能优化
硬件编解码
内存管理
断点续转
批量队列管理
关键要点
音画同步
格式兼容性
转码中断处理
速度与质量平衡
一句话总结:视频转码的核心是"解码→处理→编码"三段式管线,关键在于选择合适的编码格式和码率控制策略,同时做好内存管理、音画同步和异常处理。批量转码时使用队列管理器,确保任务有序执行。
记住三个关键:
- 先分析再转码——获取源视频信息,计算最优配置,避免无意义的转码
- 码率是核心参数——码率决定画质和文件大小的平衡点,根据分辨率和帧率计算推荐值
- 异常处理要完备——转码是最容易出错的环节,断点续转、进度保存、错误恢复一个都不能少
- 点赞
- 收藏
- 关注作者
评论(0)