【鸿蒙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();
  }
} 
相关推荐
早點睡3903 小时前
高级进阶 React Native 鸿蒙跨平台开发:@react-native-community-slider 滑块组件
react native·react.js·harmonyos
Android系统攻城狮3 小时前
Android16进阶之音频播放定位MediaPlayer.seekTo调用流程与实战(二百二十七)
音视频·mediaplayer·android16·音频进阶·音频性能实战
一只大侠的侠3 小时前
Flutter开源鸿蒙跨平台训练营 Day11从零开发商品详情页面
flutter·开源·harmonyos
一只大侠的侠3 小时前
React Native开源鸿蒙跨平台训练营 Day18自定义useForm表单管理实战实现
flutter·开源·harmonyos
一只大侠的侠4 小时前
React Native开源鸿蒙跨平台训练营 Day20自定义 useValidator 实现高性能表单验证
flutter·开源·harmonyos
晚霞的不甘4 小时前
Flutter for OpenHarmony 可视化教学:A* 寻路算法的交互式演示
人工智能·算法·flutter·架构·开源·音视频
听麟5 小时前
HarmonyOS 6.0+ 跨端智慧政务服务平台开发实战:多端协同办理与电子证照管理落地
笔记·华为·wpf·音视频·harmonyos·政务
前端世界5 小时前
从单设备到多设备协同:鸿蒙分布式计算框架原理与实战解析
华为·harmonyos
晚霞的不甘5 小时前
Flutter for OpenHarmony 实现计算几何:Graham Scan 凸包算法的可视化演示
人工智能·算法·flutter·架构·开源·音视频
一只大侠的侠6 小时前
Flutter开源鸿蒙跨平台训练营 Day12从零开发通用型登录页面
flutter·开源·harmonyos