【鸿蒙HarmonyOS Next App实战开发】视频提取音频
在多媒体处理场景中,经常需要从视频文件中提取纯净的音频轨道。本文将介绍如何在HarmonyOS应用中实现这一功能,核心代码基于@ohos/mp4parser
库的FFmpeg能力。
功能概述
我们实现了一个完整的视频音频提取页面,包含以下功能:
- 通过系统选择器选取视频文件
- 将视频复制到应用沙箱目录
- 使用FFmpeg命令提取音频
- 将生成的音频文件保存到公共下载目录
实现详解
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 = \'选择视频\';
优化点分析
- 临时文件清理:无论提取成功与否,都会尝试删除临时文件
- 错误处理:每个关键步骤都包含try-catch错误捕获
- 权限隔离:通过沙箱机制处理敏感文件操作
注意事项
- 模块依赖:需要提前配置好
mp4parser
的FFmpeg能力 - 存储权限:操作公共目录需要申请对应权限
- 大文件处理:实际生产环境应考虑分块读写避免内存溢出
效果展示
- 视频选择界面
- 完成后的提示弹窗
总结
本文介绍的方案实现了完整的视频音频提取功能,充分利用了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(); }}