【鸿蒙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<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 = '选择视频';

优化点分析

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

注意事项

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

效果展示

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

总结

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

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

完整代码

TypeScript 复制代码
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();
  }
} 
相关推荐
zhanshuo几秒前
掌握 ArkTS 复杂数据绑定:从双向输入到多组件状态同步
harmonyos
SuperHeroWu71 小时前
【HarmonyOS】鸿蒙应用开发中常用的三方库介绍和使用示例
华为·harmonyos
jz_ddk2 小时前
[HarmonyOS] 鸿蒙LiteOS-A内核深度解析 —— 面向 IoT 与智能终端的“小而强大”内核
物联网·学习·华为·harmonyos
爱笑的眼睛112 小时前
HarmonyOS中的PX、 VP、 FP 、LPX、Percentage、Resource 详细区别是什么
华为
lovep15 小时前
CLAP文本-音频基础模型: LEARNING AUDIO CONCEPTS FROM NATURAL LANGUAGE SUPERVISION
音视频·语音识别·多模态模型·音频识别·基础模型
爱笑的眼睛116 小时前
HarmonyOS应用上架流程详解
华为·harmonyos
源码_V_saaskw1 天前
JAVA图文短视频交友+自营商城系统源码支持小程序+Android+IOS+H5
java·微信小程序·小程序·uni-app·音视频·交友
zhanshuo1 天前
构建可扩展的状态系统:基于 ArkTS 的模块化状态管理设计与实现
harmonyos
zhanshuo1 天前
ArkTS 模块通信全解析:用事件总线实现页面消息联动
harmonyos