纯前端FFmpeg.js实现长视频切割,音频提取,关键帧提取

3,737 阅读12分钟

背景:

最近遇到一个需求:需要对长视频的音频做提取,最后对音频识别出文字,算法和后端都可以对视频进行视频切割和提取,那我就想前端能不能实现呢,就开始调研方案。

后端和算法使用的都是FFmpeg这个库实现视频的操作,其实咱们前端也有FFmpeg.js,能对视频进行操作。

一、FFmpeg.wasm 简介

先讲FFmpeg,FFmpeg 是一个开源的音视频处理工具,支持录制、转换和流式传输音视频。它提供了丰富的功能,如格式转换、视频剪辑、音频提取等。

FFmpeg官网链接;其本身是一个开源的客户端工具,同时也提供了多种语言的api支持。

FFmpeg.wasm是c语言编写的程序,通过编译后可以在web平台上使用(浏览器),

原理:WebAssembly(简称wasm)是一个虚拟指令集体系架构(virtual ISA),整体架构包括核心的ISA定义、二进制编码、程序语义的定义与执行,以及面向不同的嵌入环境(如Web)的应用编程接口(WebAssembly API)。其目标是为 C/C++等语言编写的程序经过编译,在确保安全和接近原生应用的运行速度更好地在 Web 平台上运行。

简单来说:WebAssembly 是一种高效的二进制格式,能够在现代浏览器中快速执行。

FFmpegWasm:github链接

运行原理:

  • 加载和初始化: 当用户在浏览器中加载 ffmpeg.js 时,浏览器会下载编译后的 WebAssembly 模块和相关的 JavaScript 代码。

  • 调用 FFmpeg 功能: 用户可以通过 JavaScript 调用 FFmpeg 的命令和功能,传递参数和数据。ffmpeg.js 会将这些调用转换为相应的 WebAssembly 函数调用。

  • 处理音视频: FFmpeg 在浏览器中执行音视频处理任务,使用虚拟文件系统读取输入文件,进行处理,然后将结果输出到虚拟文件系统中。

  • 下载结果: 处理完成后,用户可以通过 JavaScript 下载处理后的文件,所有操作都在客户端完成,无需与服务器交互。

二、常用FFmpeg方法

ffmpeg.js 提供了一系列方法来处理音视频文件。以下是一些常用的方法和功能:

1. 基本方法

  • FFmpeg.load():

    • 加载 FFmpeg 的核心库。这个方法需要在使用其他 FFmpeg 功能之前调用。
  • FFmpeg.run(...args):

    • 执行 FFmpeg 命令。参数是一个字符串数组,表示要执行的命令及其参数。例如:
      await ffmpeg.run('-i', 'input.mp4', 'output.avi');
      

2. 文件操作

  • FFmpeg.FS(command, filename, data):

    • 在虚拟文件系统中执行文件操作。常用的命令包括:
      • writeFile: 将数据写入虚拟文件系统。
      • readFile: 从虚拟文件系统读取文件。
      • unlink: 删除虚拟文件系统中的文件。
    // 写入文件
    ffmpeg.FS('writeFile', 'input.mp4', await fetchFile('path/to/local/file.mp4'));
    
    // 读取文件
    const data = ffmpeg.FS('readFile', 'output.avi');
    

3. 音视频处理

  • 格式转换:

    • 使用 run 方法进行格式转换。例如,将 MP4 转换为 AVI:
      await ffmpeg.run('-i', 'input.mp4', 'output.avi');
      
  • 视频剪辑:

    • 可以通过指定时间戳来剪辑视频。例如,剪辑从 10 秒到 20 秒的片段:
      await ffmpeg.run('-i', 'input.mp4', '-ss', '10', '-to', '20', 'output.mp4');
      
  • 音频提取:

    • 从视频中提取音频:
      await ffmpeg.run('-i', 'input.mp4', '-q:a', '0', '-map', 'a', 'output.mp3');
      

4. 视频压缩和调整

  • 视频压缩:

    • 可以通过调整比特率来压缩视频:
      await ffmpeg.run('-i', 'input.mp4', '-b:v', '1000k', 'output.mp4');
      
  • 调整分辨率:

    • 改变视频的分辨率:
      await ffmpeg.run('-i', 'input.mp4', '-vf', 'scale=640:360', 'output.mp4');
      

5. 其他功能

  • 添加水印:

    • 可以在视频上添加水印:
      await ffmpeg.run('-i', 'input.mp4', '-i', 'watermark.png', '-filter_complex', 'overlay=10:10', 'output.mp4');
      
  • 合并视频:

    • 合并多个视频文件:
      await ffmpeg.run('-i', 'concat:input1.mp4|input2.mp4', '-c', 'copy', 'output.mp4');
      

6. 获取处理结果

  • 获取处理后的文件:
    • 使用 readFile 方法获取处理后的文件数据,并可以将其转换为 Blob 以便下载:
      const data = ffmpeg.FS('readFile', 'output.mp4');
      const blob = new Blob([data.buffer], { type: 'video/mp4' });
      const url = URL.createObjectURL(blob);
      

三、具体功能实现

需求:
本地选择长视频,允许对长视频选择时间段裁剪,音频提取,图片帧提取。

1、ffmpeg引入:

npm install vue video.js @ffmpeg/ffmpeg

这里需要注意,安装依赖的时候我出现报错了 ,所以参考FFmpeg安装避坑,最后有效安装。

步骤:

1、手动在package.json文件中加入配置后下载

image.png

2、将nodemodules中的这三个文件复制到pubilc下

image.png

启动项目之后可以使用ffmpeg的能力了

2、切割视频

  /**
     * 切割视频方法
     * 使用FFmpeg对视频进行时间段切割
     * 步骤:
     * 1. 设置处理状态并加载FFmpeg
     * 2. 获取原视频文件并写入FFmpeg文件系统
     * 3. 执行FFmpeg命令进行切割
     * 4. 读取切割后的视频并创建下载链接
     * 5. 将结果添加到处理文件列表中
     */
    async cutVideo() {
      try {
        // 设置处理状态
        this.processing = true;
        this.processingStatus = '正在切割视频...';
        
        // 确保FFmpeg已加载
        await this.loadFFmpeg();

        // 获取视频文件并写入FFmpeg虚拟文件系统
        const videoBlob = await fetch(this.videoSource).then(r => r.blob());
        ffmpeg.FS('writeFile', 'input.mp4', await fetchFile(videoBlob));

        // 执行FFmpeg命令进行视频切割
        // -i: 输入文件
        // -ss: 开始时间点(秒)
        // -t: 持续时间(秒)
        // -c copy: 复制编解码器(不重新编码,速度快)
        await ffmpeg.run(
          '-i', 'input.mp4',
          '-ss', `${this.startTime}`,
          '-t', `${this.endTime - this.startTime}`,
          '-c', 'copy',
          'output.mp4'
        );

        // 从FFmpeg文件系统读取切割后的视频
        const data = ffmpeg.FS('readFile', 'output.mp4');
        // 创建Blob对象用于下载
        const blob = new Blob([data.buffer], { type: 'video/mp4' });
        
        // 将处理结果添加到文件列表
        this.processedFiles.push({
          name: 'cut_video.mp4', // 文件名
          type: 'video', // 文件类型
          blob: blob, // 文件数据
          url: this.createDownloadUrl(blob) // 下载链接
        });

      } catch (error) {
        console.error('视频切割失败:', error);
      } finally {
        // 重置处理状态
        this.processing = false;
      }
    },

打印如下: image.png 结果如下:

image.png

3、提取音频

     * 提取音频方法
     * 使用FFmpeg从视频中提取音频轨道并转换为MP3格式
     * 步骤:
     * 1. 设置处理状态并加载FFmpeg
     * 2. 从processedFiles中获取已处理的视频文件
     * 3. 将视频写入FFmpeg文件系统
     * 4. 执行FFmpeg命令提取音频
     * 5. 读取提取的音频并创建下载链接
     * 6. 将结果添加到处理文件列表中
     */
    async extractAudio() {
      try {
        // 设置处理状态
        this.processing = true;
        this.processingStatus = '正在提取音频...';
        
        // 确保FFmpeg已加载
        await this.loadFFmpeg();

        // 从处理文件列表中获取视频文件
        const videoFile = this.processedFiles.find(f => f.type === 'video');
        if (!videoFile) throw new Error('未找到视频文件');

        // 将视频文件写入FFmpeg虚拟文件系统
        ffmpeg.FS('writeFile', 'input.mp4', await fetchFile(videoFile.blob));

        // 执行FFmpeg命令提取音频
        // -i: 输入文件
        // -vn: 禁用视频输出
        // -acodec libmp3lame: 使用LAME编码器将音频编码为MP3格式
        await ffmpeg.run(
          '-i', 'input.mp4',
          '-vn',
          '-acodec', 'libmp3lame',
          'audio.mp3'
        );

        // 从FFmpeg文件系统读取提取的音频
        const data = ffmpeg.FS('readFile', 'audio.mp3');
        // 创建Blob对象用于下载
        const blob = new Blob([data.buffer], { type: 'audio/mp3' });
        
        // 将处理结果添加到文件列表
        this.processedFiles.push({
          name: 'extracted_audio.mp3', // 文件名
          type: 'audio', // 文件类型
          blob: blob, // 文件数据
          url: this.createDownloadUrl(blob) // 下载链接
        });

      } catch (error) {
        // 错误处理
        console.error('音频提取失败:', error);
      } finally {
        // 重置处理状态
        this.processing = false;
      }
    },

音频提取打印:

image.png 结果如下:

image.png

4、视频帧提取

     * 从视频中提取关键帧
     * 使用FFmpeg每60秒提取一帧图像,并保存为JPEG格式
     * 提取的帧会被添加到processedFiles数组中供后续使用
     */
    async extractFrames() {
      try {
        // 设置处理状态为正在进行中
        this.processing = true;
        this.processingStatus = '正在抽取关键帧...';

        // 确保FFmpeg已加载完成
        await this.loadFFmpeg();

        // 从处理文件列表中查找视频文件
        const videoFile = this.processedFiles.find(f => f.type === 'video');
        if (!videoFile) throw new Error('未找到视频文件');

        // 将视频文件写入FFmpeg虚拟文件系统
        ffmpeg.FS('writeFile', 'input.mp4', await fetchFile(videoFile.blob));

        // 执行FFmpeg命令提取帧
        // -i: 指定输入文件
        // -vf fps=1/60: 设置视频过滤器,每60秒提取1帧
        // -frame_pts 1: 在输出文件名中包含显示时间戳
        // frame_%d.jpg: 输出文件名格式,_%d会被替换为帧序号
        await ffmpeg.run(
          '-i', 'input.mp4',
          '-vf', 'fps=1/60',
          '-frame_pts', '1',
          'frame_%d.jpg'
        );

        // 存储提取的帧
        const frames = [];
        // 遍历提取的帧,最多提取maxFrames个
        for (let i = 1; i <= this.maxFrames; i++) {
          try {
            // 从FFmpeg文件系统读取帧数据
            const data = ffmpeg.FS('readFile', `frame_${i}.jpg`);
            // 创建Blob对象用于预览和下载
            const blob = new Blob([data.buffer], { type: 'image/jpeg' });
            // 将帧信息添加到数组
            frames.push({
              name: `frame_${i}.jpg`, // 帧文件名
              type: 'image', // 文件类型
              blob: blob, // 帧数据
              url: this.createDownloadUrl(blob) // 创建下载链接
            });
          } catch (e) {
            // 如果读取失败,说明没有更多帧了,退出循环
            break;
          }
        }

        // 将提取的帧添加到处理文件列表
        this.processedFiles.push(...frames);

      } catch (error) {
        // 错误处理
        console.error('帧提取失败:', error);
      } finally {
        // 重置处理状态
        this.processing = false;
      }
    }
  },

结果如下:

image.png

遇到的问题:

在引入之后发现跨域问题:ReferenceError: SharedArrayBuffer is not defined

别慌:在vue.config.js中增加下面的配置:(我使用的是vite,webpack自行修改)

  devServer: {
    headers: {
      "Cross-Origin-Opener-Policy": "same-origin", // 保护你的源站点免受攻击
      "Cross-Origin-Embedder-Policy": "require-corp", // 保护受害者免受你的源站点的影响
    },
  }

上线后还有这样的问题,增加nginx配置:

add_header Cross-Origin-Opener-Policy same-origin;
add_header Cross-Origin-Embedder-Policy require-corp;
add_header Cross-Origin-Resource-Policy same-origin;

完整代码:

  <div class="video-player">
    <div class="file-input-container" v-if="!videoSource">
      <div
        class="upload-area"
        @click="triggerFileInput"
        @dragover.prevent
        @drop.prevent="handleDrop"
      >
        <input
          type="file"
          ref="fileInput"
          @change="onFileChange"
          accept="video/*"
          class="hidden-input"
        />
        <div class="upload-content">
          <div class="upload-icon">
            <i class="fas fa-cloud-upload-alt"></i>
          </div>
          <h3>选择或拖拽视频文件到此处</h3>
          <p>支持的格式: MP4, MOV, AVI, etc.</p>
          <button class="select-button">选择视频文件</button>
        </div>
      </div>
    </div>
    <div v-if="videoSource">
      <h2 class="video-title" :title="allOriginalFileName">
        {{ allOriginalFileName }}
      </h2>
      <div class="player-container">
        <video
          ref="videoPlayer"
          class="video-js vjs-default-skin"
          controls
          preload="auto"
          width="500"
          height="380"
        >
          <source :src="videoSource" type="video/mp4" />
          你的浏览器不支持video标签
        </video>
      </div>

      <div class="controls" v-if="videoSource">
        <button @click="playPause">{{ isPlaying ? "暂停" : "播放" }}</button>
        <input
          type="range"
          min="0"
          :max="duration"
          :value="currentTime"
          @input="onSeek"
          class="seek-bar"
        />
        <span>{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
        <button @click="fastForward">快进</button>
        <button @click="rewind">快退</button>
      </div>
      <div class="time-segment-container">
        <div class="time-segment-label">分析时间段:</div>
        <!-- 添加时间段选择控件 -->
        <div class="time-selection" v-if="videoSource">
          <div class="time-input">
            <label>开始时间:</label>
            <input type="number" v-model="startTime" min="0" :max="duration" />
            秒
          </div>
          <div class="time-input">
            <label>结束时间:</label>
            <input
              type="number"
              v-model="endTime"
              :min="startTime"
              :max="duration"
            />
            秒
          </div>
        </div>
      </div>

      <!-- 添加功能按钮 -->
      <div class="function-buttons" v-if="videoSource">
        <button @click="cutVideo" :disabled="!canCut">模型分析视频</button>

        <button @click="resetVideo">重新上传视频</button>
      </div>

      <!-- 处理状态和结果展示 -->
      <div v-if="processing" class="processing-status">
        <div>处理中:{{ processingStatus }}</div>
        <div class="loader">
          <div class="dot"></div>
          <div class="dot"></div>
          <div class="dot"></div>
        </div>
      </div>
      <div v-if="processedFiles.length" class="result-files">
        <div class="result-files-title">处理结果:</div>
        <ul>
          <li v-for="(file, index) in processedFiles" :key="index">
            {{ file.name }} - {{ file.type }}
            <a v-if="file.url" :href="file.url" download>下载</a>
          </li>
        </ul>
      </div>
    </div>
  </div>
</template>

<script>
import "video.js/dist/video-js.css";
import videojs from "video.js";
import FFmpeg from "@ffmpeg/ffmpeg";
const { createFFmpeg, fetchFile } = FFmpeg;
import JSZip from "jszip";
import CryptoJS from "crypto-js"; // 引入 crypto-js
const ffmpeg = createFFmpeg({
  corePath: "./ffmpeg-core.js", // 核心文件的路径
  log: true, // 是否在控制台打印日志,true => 打印
});
import EventBus from "./EventBus";
import "./LeftUpload.css"; // 引入 CSS 文件
export default {
  name: "HelloWorld",
  props: {
    msg: String,
  },
  data() {
    return {
      videoSource: null,
      isPlaying: false,
      currentTime: 0,
      duration: 0,
      player: null,
      startTime: 0, // 切割开始时间
      endTime: 0, // 切割结束时间
      processing: false, // 处理状态
      processingStatus: "", // 处理状态描述
      processedFiles: [], // 处理结果文件列表
      ffmpegLoaded: false, // ffmpeg加载状态
      originalFileName: "", // 存储原始文件名
      allOriginalFileName: "",
      fileCounter: {
        mp4: 0,
        mp3: 0,
        image: 0,
        zip: 0,
      },
    };
  },
  computed: {
    /**
     * 判断是否可以进行视频切割
     * 条件:
     * 1. 开始时间小于结束时间
     * 2. 结束时间不超过视频总时长
     * 3. 当前没有正在进行的处理任务
     * @returns {boolean} 是否可以进行切割
     */
    canCut() {
      return (
        this.startTime < this.endTime &&
        this.endTime <= this.duration &&
        !this.processing
      );
    },

    /**
     * 判断是否可以进行视频处理
     * 条件:
     * 1. 已处理文件列表中存在视频类型文件
     * 2. 当前没有正在进行的处理任务
     * @returns {boolean} 是否可以进行处理
     */
    canProcess() {
      return (
        this.processedFiles.some((f) => f.type === "video") && !this.processing
      );
    },
  },
  created() {
    this.loadFFmpeg();
  },
  methods: {
    // 添加重置视频方法
    /**
     * 重置视频播放器和相关状态
     * 步骤:
     * 1. 销毁现有的播放器实例
     * 2. 释放视频源URL占用的内存
     * 3. 重置所有视频相关的状态变量
     * 4. 清空文件输入框
     */
    resetVideo() {
      // 销毁现有的播放器实例
      if (this.player) {
        this.player.dispose();
        this.player = null;
      }

      // 释放视频源URL占用的内存
      if (this.videoSource) {
        URL.revokeObjectURL(this.videoSource);
      }

      // 重置视频播放相关状态
      this.videoSource = null;
      this.isPlaying = false;
      this.currentTime = 0;
      this.duration = 0;
      this.startTime = 0;
      this.endTime = 0;

      // 重置处理相关状态
      this.processedFiles = [];
      this.processing = false;
      this.processingStatus = "";

      // 清空文件输入框
      if (this.$refs.fileInput) {
        this.$refs.fileInput.value = "";
      }
    },
    /**
     * 触发文件选择对话框
     */
    triggerFileInput() {
      this.$refs.fileInput.click();
    },

    /**
     * 处理文件拖放事件
     * 当用户拖放文件到上传区域时触发
     * @param {DragEvent} event - 拖放事件对象
     */
    handleDrop(event) {
      const file = event.dataTransfer.files[0];
      if (file && file.type.startsWith("video/")) {
        this.handleVideoFile(file);
      }
    },

    /**
     * 处理文件选择变更事件
     * 当用户通过文件选择对话框选择文件时触发
     * @param {Event} event - 文件选择事件对象
     */
    onFileChange(event) {
      const file = event.target.files[0];
      if (file) {
        this.handleVideoFile(file);
      }
    },

    // 处理视频文件的方法
    async handleVideoFile(file) {
      // 保存原始文件名(去掉扩展名)
      this.originalFileName =
        file.name.replace(/\.[^/.]+$/, "").slice(0, 10) + "...";
      this.allOriginalFileName = file.name;

      // 销毁之前的播放器实例
      if (this.player) {
        this.player.dispose();
        this.player = null;
      }
      // 创建新的视频源URL
      this.videoSource = URL.createObjectURL(file);
      // 初始化新的播放器
      this.$nextTick(() => {
        this.initializePlayer();
      });
      // 生成视频的唯一MD5 ID
      const md5Id = await this.generateMD5(file);
      console.log("视频的唯一MD5 ID:", md5Id); // 打印MD5 ID
    },

    // 生成MD5
    async generateMD5(file) {
      const duration = 10; // 截取的时间长度(秒)
      const videoBlob = new Blob([file]); // 创建 Blob 对象
      const videoUrl = URL.createObjectURL(videoBlob); // 创建视频 URL

      // 创建视频元素以获取时长
      const videoElement = document.createElement("video");
      videoElement.src = videoUrl;

      return new Promise((resolve, reject) => {
        videoElement.onloadedmetadata = async () => {
          const videoDuration = videoElement.duration; // 获取视频时长
          const maxDuration = Math.min(videoDuration, duration); // 取最小值
          const start = 0; // 从文件开始读取
          const end = Math.floor((maxDuration * file.size) / videoDuration); // 计算结束字节
          const chunk = file.slice(start, end); // 截取文件的前部分
          const arrayBuffer = await chunk.arrayBuffer(); // 将块读取为 ArrayBuffer
          const bytes = new Uint8Array(arrayBuffer); // 转换为 Uint8Array
          // 将 Uint8Array 转换为 WordArray
          const wordArray = CryptoJS.lib.WordArray.create(bytes.buffer);
          const md5 = CryptoJS.MD5(wordArray).toString(); // 计算 MD5

          resolve(md5); // 返回 MD5 哈希
        };

        videoElement.onerror = (error) => {
          reject(error); // 处理错误
        };
      });
    },
    generateFileName(type) {
      // 增加计数器
      this.fileCounter[type]++;
      // 格式化时间段
      const timeSegment = `_${Math.floor(this.startTime)}-${Math.floor(
        this.endTime
      )}s`;
      console.log(this.originalFileName);
      console.log(timeSegment);
      console.log(this.fileCounter[type]);
      // 根据类型生成不同的文件名
      switch (type) {
        case "mp4":
          return `${this.originalFileName}${timeSegment}-${this.fileCounter[type]}.mp4`;
        case "mp3":
          return `${this.originalFileName}${timeSegment}-${this.fileCounter[type]}.mp3`;
        case "image":
          return `${this.originalFileName}${timeSegment}-${this.fileCounter[type]}.jpg`;
        case "zip":
          return `${this.originalFileName}${timeSegment}-${this.fileCounter[type]}.zip`;
        default:
          return `${this.originalFileName}_${type}_${this.fileCounter[type]}`;
      }
    },

    /**
     * 初始化视频播放器
     * 设置视频加载完成和时间更新的事件监听
     */
    initializePlayer() {
      this.player = videojs(this.$refs.videoPlayer, {}, () => {
        // 视频元数据加载完成时的处理
        this.player.on("loadedmetadata", () => {
          this.duration = Math.floor(this.player.duration());
          this.endTime = this.duration;
        });

        // 视频播放时间更新时的处理
        this.player.on("timeupdate", () => {
          this.currentTime = this.player.currentTime();
        });
      });
    },

    /**
     * 控制视频播放/暂停
     * 切换播放状态并更新isPlaying标志
     */
    playPause() {
      if (this.player.paused()) {
        this.player.play();
        this.isPlaying = true;
      } else {
        this.player.pause();
        this.isPlaying = false;
      }
    },

    /**
     * 处理视频进度条拖动事件
     * @param {Event} event - 进度条输入事件
     */
    onSeek(event) {
      const time = event.target.value;
      this.player.currentTime(time);
      this.currentTime = time;
      EventBus.$emit("updateCurrentTime", time);
    },

    /**
     * 格式化时间显示
     * @param {number} seconds - 需要格式化的秒数
     * @returns {string} 格式化后的时间字符串 (mm:ss)
     */
    formatTime(seconds) {
      const minutes = Math.floor(seconds / 60);
      const remainingSeconds = Math.floor(seconds % 60);
      return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
    },

    /**
     * 快进10秒
     * 确保不超过视频总时长
     */
    fastForward() {
      const newTime = Math.min(this.player.currentTime() + 10, this.duration);
      this.player.currentTime(newTime);
    },

    /**
     * 快退10秒
     * 确保不小于0秒
     */
    rewind() {
      const newTime = Math.max(this.player.currentTime() - 10, 0);
      this.player.currentTime(newTime);
    },

    /**
     * 加载FFmpeg库
     * 如果已加载则跳过
     */
    async loadFFmpeg() {
      // 检查FFmpeg是否已经加载
      if (!this.ffmpegLoaded) {
        await ffmpeg.load();
        this.ffmpegLoaded = true;
      } else {
        console.log("FFmpeg is already loaded.");
      }
    },

    /**
     * 创建文件下载URL
     * @param {Blob} blob - 文件blob对象
     * @returns {string} 可下载的URL
     */
    createDownloadUrl(blob) {
      return URL.createObjectURL(blob);
    },
    /**
     * 切割视频方法
     * 使用FFmpeg对视频进行时间段切割
     * 步骤:
     * 1. 设置处理状态并加载FFmpeg
     * 2. 获取原视频文件并写入FFmpeg文件系统
     * 3. 执行FFmpeg命令进行切割
     * 4. 读取切割后的视频并创建下载链接
     * 5. 将结果添加到处理文件列表中
     */
    /**
     * 切割视频方法
     * 使用FFmpeg对视频进行时间段切割
     */
    // 版本2  并行处理
    async cutVideo() {
      const startTime = Date.now(); // Start timing
      try {
        console.log("开始切割视频处理");
        this.processing = true;
        this.processingStatus = "正在切割视频";

        if (!this.ffmpegLoaded) {
          await this.loadFFmpeg();
        }

        console.log("获取视频文件...");
        const videoBlob = await fetch(this.videoSource).then((r) => r.blob());
        console.log(`获取到视频文件大小: ${videoBlob.size} bytes`);
        ffmpeg.FS("writeFile", "input.mp4", await fetchFile(videoBlob));
        console.log("视频文件写入FFmpeg完成");

        const segmentLength = 600; // 每段600秒
        const totalSegments = Math.ceil(
          (this.endTime - this.startTime) / segmentLength
        );
        console.log(`总共需要处理 ${totalSegments} 个片段`);

        const audioFiles = [];
        const frameFiles = [];

        for (let i = 0; i < totalSegments; i++) {
          const start = this.startTime + i * segmentLength;
          const duration = Math.min(segmentLength, this.endTime - start);
          console.log(
            `开始处理第 ${
              i + 1
            } 个片段, 起始时间: ${start}秒, 持续时间: ${duration}秒`
          );

          // 切割视频
          this.processingStatus = `正在切割视频段 ${i + 1}/${totalSegments}`;
          console.log(`执行视频切割命令...`);
          await ffmpeg.run(
            "-i",
            "input.mp4",
            "-ss",
            `${start}`,
            "-t",
            `${duration}`,
            "-c",
            "copy",
            `output_segment_${i}.mp4`
          );
          console.log(`视频段 ${i + 1} 切割完成`);

          // 提取音频
          this.processingStatus = `正在提取音频段 ${i + 1}/${totalSegments}`;
          console.log(`开始提取音频...`);
          await ffmpeg.run(
            "-i",
            `output_segment_${i}.mp4`,
            "-vn",
            "-acodec",
            "libmp3lame",
            "-b:a",
            "64k",
            "-ar",
            "8000",
            `audio_segment_${i}.mp3`
          );
          console.log(`音频段 ${i + 1} 提取完成`);
          audioFiles.push({
            name: `audio_segment_${i}.mp3`,
            blob: ffmpeg.FS("readFile", `audio_segment_${i}.mp3`),
          });

          // 提取关键帧
          this.processingStatus = `正在提取关键帧段 ${i + 1}/${totalSegments}`;
          const frameDir = `frames/frame_segment_${i}`;
          // 确保目录存在
          try {
            if (!ffmpeg.FS("readdir", "/").includes("frames")) {
              ffmpeg.FS("mkdir", "frames");
              console.log("frames目录创建成功");
            }
            if (
              !ffmpeg.FS("readdir", "frames").includes(`frame_segment_${i}`)
            ) {
              ffmpeg.FS("mkdir", `frames/frame_segment_${i}`);
              console.log(`frame_segment_${i}目录创建成功`);
            }
          } catch (error) {
            console.log(`目录 ${frameDir} 创建失败或已存在,错误: ${error}`);
          }
          await ffmpeg.run(
            "-i",
            `output_segment_${i}.mp4`,
            "-vf",
            "fps=1", // 修改这里,设置每秒提取一帧
            `${frameDir}/frame_%d.jpg`
          );
          // 读取关键帧文件
          let j = 1;
          let frameExists = true;
          while (frameExists) {
            const frameFileName = `${frameDir}/frame_${j}.jpg`;
            try {
              // const frameData = ffmpeg.FS("readFile", frameFileName);
              const frameData = ffmpeg.FS("readFile", frameFileName);
              const frameTime = new Date((start + j - 1) * 1000)
                .toISOString()
                .substr(11, 8)
                .replace(/:/g, "-");
              const newFrameName = `frame_${frameTime}.jpg`;
              frameFiles.push({
                name: newFrameName,
                blob: new Blob([frameData.buffer], { type: "image/jpeg" }),
                url: this.createDownloadUrl(
                  new Blob([frameData.buffer], { type: "image/jpeg" })
                ),
              });
              j++;
            } catch (error) {
              frameExists = false; // 如果读取失败,退出循环
            }
          }
        }

        // 处理完成后生成ZIP文件
        this.processingStatus = "正在生成ZIP文件";
        console.log(
          `需要打包 ${audioFiles.length} 个音频文件和 ${frameFiles.length} 个关键帧文件`
        );

        const zip = new JSZip();
        const audioFolder = zip.folder("audio");
        const framesFolder = zip.folder("frames");

        // 确保音频文件和关键帧文件都被添加到 ZIP 包中
        audioFiles.forEach((file) => {
          audioFolder.file(file.name, file.blob);
        });

        // 添加帧文件到 ZIP
        frameFiles.forEach((file) => {
          const framePath = file.name.split("/").slice(1, -1).join("/"); // 修改这里,去掉第一个frames目录
          let subFolder = framesFolder.folder(framePath); // 创建或获取子文件夹
          subFolder.file(file.name.split("/").pop(), file.blob); // 添加文件
        });

        const zipBlob = await zip.generateAsync({ type: "blob" });
        console.log(`ZIP文件生成完成, 大小: ${zipBlob.size} bytes`);

        const zipFileName = this.generateFileName("zip");
        const zipUrl = this.createDownloadUrl(zipBlob);
        this.processedFiles.push({
          name: zipFileName,
          type: "zip",
          blob: zipBlob,
          url: zipUrl,
        });

        this.processingStatus = "处理完成"; // 更新处理状态
        this.processing = false;
        console.log("视频处理全部完成");
        const endTime = Date.now(); // End timing
        const processingTime = (endTime - startTime) / 1000; // Calculate duration in seconds
        console.log(`处理完成,总耗时: ${processingTime}秒`);
      } catch (error) {
        console.error("视频切割失败:", error);
        console.log("错误详情:", error.message);
        console.log("错误堆栈:", error.stack);
        this.processing = false;
      }
    },
  },
  beforeUnmount() {
    if (this.player) {
      this.player.dispose();
    }
    // 清理所有创建的URL
    if (this.videoSource) {
      URL.revokeObjectURL(this.videoSource);
    }
    this.processedFiles.forEach((file) => {
      if (file.url) {
        URL.revokeObjectURL(file.url);
      }
    });
  },
};
</script>

我的代码中压缩部分可略过 看提取代码即可 为了分割长视频效率,采用分割600s为一段,并行处理

image.png

后续我尝试一下前端实现音频文字的提取。

四、总结

ffmpeg.js 提供了丰富的功能来处理音视频文件,包括格式转换、剪辑、提取音频、压缩、调整分辨率、添加水印等。通过这些方法,开发者可以在浏览器中实现强大的音视频处理功能。 能够在浏览器中运行而不需要安装其他工具,主要是因为它利用了 WebAssembly 和 Emscripten 技术,将 FFmpeg 编译为可以在浏览器中高效执行的格式。通过虚拟文件系统和浏览器 API,ffmpeg.js 实现了音视频处理的完整功能,提供了一个强大的客户端解决方案。