在多媒体处理场景中,经常需要从视频文件中提取纯净的音频轨道。本文将介绍如何在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<string|undefined> {// 创建沙箱路径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
@Component
struct 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<string|undefined> {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<string|undefined> {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();}
}