在多媒體處理場景中,經常需要從視頻文件中提取純凈的音頻軌道。本文將介紹如何在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();}
}