> 技术文档 > 【鸿蒙HarmonyOS Next App实战开发】视频提取音频

【鸿蒙HarmonyOS Next App实战开发】视频提取音频

在多媒体处理场景中,经常需要从视频文件中提取纯净的音频轨道。本文将介绍如何在HarmonyOS应用中实现这一功能,核心代码基于@ohos/mp4parser库的FFmpeg能力。

功能概述

我们实现了一个完整的视频音频提取页面,包含以下功能:

  1. 通过系统选择器选取视频文件
  2. 将视频复制到应用沙箱目录
  3. 使用FFmpeg命令提取音频
  4. 将生成的音频文件保存到公共下载目录

实现详解

1. 视频选择与沙箱准备

视频选择使用PhotoViewPicker组件,限定选择类型为视频文件:

private async selectVideo() { // 创建视频选择器 let context = getContext(this) as common.Context; let photoPicker = new picker.PhotoViewPicker(context); let photoSelectOptions = new picker.PhotoSelectOptions(); photoSelectOptions.MIMEType = picker.PhotoViewMIMETypes.VIDEO_TYPE; // ...其他设置}

选择视频后,为防止权限问题,我们将视频复制到应用沙箱目录:

private async copyFileToSandbox(sourcePath: string): Promise { // 创建沙箱路径 const sandboxPath = getContext(this).cacheDir + \"/temp_video.mp4\"; // 读写文件操作... // 具体代码略...}
2. FFmpeg音频提取

核心提取功能通过MP4Parser模块实现:

MP4Parser.ffmpegCmd( `ffmpeg -y -i \"${sandboxVideoPath}\" -vn -acodec libmp3lame -q:a 2 \"${sandboxAudioPath}\"`, callBack);

关键参数说明:

  • -vn:禁止视频输出
  • -acodec libmp3lame:指定MP3编码器
  • -q:a 2:设置音频质量(2表示较高品质)
3. 结果保存

音频提取完成后,将文件移动到公共目录:

const documentViewPicker = new picker.DocumentViewPicker(context);const result = await documentViewPicker.save(documentSaveOptions);// 在回调中处理文件写入const targetPath = new fileUri.FileUri(uri + \'/\'+ audioName).path;// ...写入操作
4. 状态管理与用户体验

提取过程中通过状态变量控制UI显示:

@State isExtracting: boolean = false;@State btnText: string = \'选择视频\';// 提取开始时更新状态this.isExtracting = true;this.btnText = \'正在提取...\';// 完成时恢复状态that.isExtracting = false;that.btnText = \'选择视频\';

优化点分析

  1. ​临时文件清理​​:无论提取成功与否,都会尝试删除临时文件
  2. ​错误处理​​:每个关键步骤都包含try-catch错误捕获
  3. ​权限隔离​​:通过沙箱机制处理敏感文件操作

注意事项

  1. ​模块依赖​​:需要提前配置好mp4parser的FFmpeg能力
  2. ​存储权限​​:操作公共目录需要申请对应权限
  3. ​大文件处理​​:实际生产环境应考虑分块读写避免内存溢出

效果展示

  • 视频选择界面
  • 完成后的提示弹窗

总结

本文介绍的方案实现了完整的视频音频提取功能,充分利用了HarmonyOS的文件管理和FFmpeg处理能力。核心代码约200行,展示了从视频选择到音频生成的关键流程。开发者可基于此方案扩展更复杂的多媒体处理功能。

具体效果华为应用商店搜索【图影工具箱】查看

完整代码

import { MP4Parser } from \"@ohos/mp4parser\";import { ICallBack } from \"@ohos/mp4parser\";import { fileIo as fs } from \'@kit.CoreFileKit\';import { fileUri, picker } from \'@kit.CoreFileKit\';import { common } from \'@kit.AbilityKit\';import { TitleBar } from \"../components/TitleBar\";@Entry@Componentstruct AudioExtractPage { @State btnText: string = \'选择视频\'; @State selectedVideoPath: string = \'\'; @State isExtracting: boolean = false; @State imageWidth: number = 0; @State imageHeight: number = 0; getResourceString(res: Resource) { return getContext().resourceManager.getStringSync(res.id) } build() { Column() { // 顶部栏 TitleBar({ title: \'视频音频提取\' }) if (this.selectedVideoPath) { Text(\'已选择视频:\' + this.selectedVideoPath) .fontSize(16) .margin({ bottom: 20 }) } Button(this.btnText, { type: ButtonType.Normal, stateEffect: true }) .borderRadius(8) .backgroundColor(0x317aff) .width(250) .margin({ top: 15 }) .onClick(() => { if (!this.isExtracting) { this.selectVideo(); } }) if (this.isExtracting) { Image($r(\'app.media.icon_load\')) .objectFit(ImageFit.None) .width(this.imageWidth) .height(this.imageHeight) .border({ width: 0 }) .borderStyle(BorderStyle.Dashed) } } .width(\'100%\') .height(\'100%\') .backgroundColor($r(\'app.color.index_tab_bar\')) } private async selectVideo() { try { let context = getContext(this) as common.Context; let photoPicker = new picker.PhotoViewPicker(context); let photoSelectOptions = new picker.PhotoSelectOptions(); photoSelectOptions.MIMEType = picker.PhotoViewMIMETypes.VIDEO_TYPE; photoSelectOptions.maxSelectNumber = 1; let result = await photoPicker.select(photoSelectOptions); console.info(\'PhotoViewPicker.select result: \' + JSON.stringify(result)); if (result && result.photoUris && result.photoUris.length > 0) { this.selectedVideoPath = result.photoUris[0]; console.info(\'Selected video path: \' + this.selectedVideoPath); this.extractAudio(); } } catch (err) { console.error(\'选择视频失败:\' + JSON.stringify(err)); AlertDialog.show({ message: \'选择视频失败\' }); } } private async copyFileToSandbox(sourcePath: string): Promise { try { // 获取沙箱目录路径 const sandboxPath = getContext(this).cacheDir + \"/temp_video.mp4\"; // 读取源文件内容 const sourceFd = await fs.open(sourcePath, fs.OpenMode.READ_ONLY); const fileStats = await fs.stat(sourceFd.fd); const buffer = new ArrayBuffer(fileStats.size); await fs.read(sourceFd.fd, buffer); await fs.close(sourceFd); // 写入到沙箱目录 const targetFd = await fs.open(sandboxPath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE); await fs.write(targetFd.fd, buffer); await fs.close(targetFd); return sandboxPath; } catch (err) { console.error(\'复制文件到沙箱失败:\' + err); return undefined; } } private async moveToPublicDirectory(sourcePath: string): Promise { try { const documentSaveOptions = new picker.DocumentSaveOptions(); documentSaveOptions.pickerMode = picker.DocumentPickerMode.DOWNLOAD; let context = getContext(this) as common.Context; const documentViewPicker = new picker.DocumentViewPicker(context); const result = await documentViewPicker.save(documentSaveOptions); if (result && result.length > 0) { const uri = result[0]; console.info(\'documentViewPicker.save succeed and uri is:\' + uri); // 读取源文件内容 const sourceFd = await fs.open(sourcePath, fs.OpenMode.READ_ONLY); const fileStats = await fs.stat(sourcePath); const buffer = new ArrayBuffer(fileStats.size); await fs.read(sourceFd.fd, buffer); await fs.close(sourceFd); // 写入到目标文件 const audioName = \'extracted_audio_\' + new Date().getTime() + \'.mp3\'; const targetPath = new fileUri.FileUri(uri + \'/\'+ audioName).path; const targetFd = await fs.open(targetPath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE); await fs.write(targetFd.fd, buffer); await fs.close(targetFd); return audioName; } return undefined; } catch (err) { console.error(\'移动到公共目录失败:\' + err); return undefined; } } private async extractAudio() { if (!this.selectedVideoPath) { AlertDialog.show({ message: \'请先选择视频\' }); return; } this.isExtracting = true; this.imageWidth = 25; this.imageHeight = 25; this.btnText = \'正在提取...\'; try { // 1. 复制视频到沙箱目录 const sandboxVideoPath = await this.copyFileToSandbox(this.selectedVideoPath); // 2. 在沙箱目录中执行ffmpeg命令 const sandboxAudioPath = getContext(this).cacheDir + \"/temp_audio.mp3\"; const that = this; let callBack: ICallBack = { async callBackResult(code: number) { that.isExtracting = false; that.imageWidth = 0; that.imageHeight = 0; that.btnText = \'选择视频\'; if (code == 0) { try {  // 3. 将音频文件移动到公共目录  const publicPath = await that.moveToPublicDirectory(sandboxAudioPath);  AlertDialog.show({  message: \'音频提取成功,保存路径:我的手机/Download(下载)/图影工具箱/\' + publicPath  }); } catch (err) {  console.error(\'移动文件失败:\' + err);  AlertDialog.show({ message: \'音频提取成功但保存失败\' }); } } else { AlertDialog.show({ message: \'音频提取失败\' }); } // 清理临时文件 try { await fs.unlink(sandboxVideoPath); await fs.unlink(sandboxAudioPath); } catch (err) { console.error(\'清理临时文件失败:\' + err); } } } // 使用ffmpeg命令提取音频 MP4Parser.ffmpegCmd( `ffmpeg -y -i \"${sandboxVideoPath}\" -vn -acodec libmp3lame -q:a 2 \"${sandboxAudioPath}\"`, callBack ); } catch (err) { this.isExtracting = false; this.imageWidth = 0; this.imageHeight = 0; this.btnText = \'选择视频\'; console.error(\'提取过程出错:\' + err); AlertDialog.show({ message: \'提取过程出错\' }); } } aboutToAppear() { MP4Parser.openNativeLog(); }}