5分钟上手:ffmpeg.wasm + React打造浏览器视频剪辑神器

【免费下载链接】ffmpeg.wasm FFmpeg for browser, powered by WebAssembly 【免费下载链接】ffmpeg.wasm 项目地址: https://gitcode.com/gh_mirrors/ff/ffmpeg.wasm

你还在为视频剪辑需要安装庞大软件而烦恼吗?还在为服务器视频处理的高昂成本而担忧吗?本文将带你使用ffmpeg.wasm和React,在浏览器中直接构建高性能视频编辑组件,无需后端支持,所有处理都在本地完成,保护用户隐私的同时提升处理速度。

读完本文你将学到:

  • 如何在React项目中集成ffmpeg.wasm
  • 实现视频格式转换、剪辑、合并等常用功能
  • 优化ffmpeg.wasm性能的实用技巧
  • 构建完整的视频编辑组件

技术原理与架构

ffmpeg.wasm是一个将FFmpeg编译为WebAssembly(Wasm)的项目,使得在浏览器环境中直接运行FFmpeg成为可能。WebAssembly是一种低级二进制指令格式,它允许高性能的代码在Web浏览器中运行,接近原生应用的速度。

ffmpeg.wasm架构

FFmpeg核心功能通过WebWorker在后台线程中运行,避免阻塞主线程,确保UI的流畅响应。如packages/ffmpeg/src/classes.ts中定义的FFmpeg类所示,它负责管理WebWorker的创建、消息传递和生命周期:

// FFmpeg类核心结构
export class FFmpeg {
  #worker: Worker | null = null;
  #resolves: Callbacks = {};
  #rejects: Callbacks = {};
  #logEventCallbacks: LogEventCallback[] = [];
  #progressEventCallbacks: ProgressEventCallback[] = [];
  public loaded = false;
  
  // 核心方法
  public load = (...);       // 加载FFmpeg核心
  public exec = (...);       // 执行FFmpeg命令
  public writeFile = (...);  // 写入文件
  public readFile = (...);   // 读取文件
  public terminate = (...);  // 终止工作线程
}

快速开始:项目搭建与配置

创建React项目

首先,我们使用Vite创建一个新的React项目:

npm create vite@latest react-ffmpeg-demo -- --template react-ts
cd react-ffmpeg-demo
npm install

安装依赖

安装ffmpeg.wasm相关包:

npm install @ffmpeg/ffmpeg @ffmpeg/util

配置Vite

修改vite.config.ts,添加必要的配置以支持ffmpeg.wasm:

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  optimizeDeps: {
    exclude: ["@ffmpeg/ffmpeg", "@ffmpeg/util"],
  },
  server: {
    headers: {
      "Cross-Origin-Opener-Policy": "same-origin",
      "Cross-Origin-Embedder-Policy": "require-corp",
    },
  },
});

以上配置解决了两个关键问题:

  1. 排除ffmpeg包的依赖优化,避免构建错误
  2. 设置跨域头,确保WebAssembly正确加载

核心功能实现

基础视频格式转换

下面实现一个基础的视频格式转换功能。我们将创建一个组件,允许用户上传视频文件,将其转换为MP4格式,并显示转换后的视频。

// src/components/VideoConverter.tsx
import { useState, useRef } from "react";
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { toBlobURL, fetchFile } from "@ffmpeg/util";

const VideoConverter = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [message, setMessage] = useState("");
  const [outputUrl, setOutputUrl] = useState("");
  const ffmpegRef = useRef<FFmpeg | null>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);

  // 初始化FFmpeg
  const initFFmpeg = async () => {
    if (ffmpegRef.current) return ffmpegRef.current;
    
    const ffmpeg = new FFmpeg();
    setIsLoading(true);
    setMessage("加载ffmpeg核心库...");
    
    try {
      // 使用国内CDN加载ffmpeg核心
      const baseURL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@0.12.10/dist/esm";
      
      await ffmpeg.load({
        coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
        wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"),
        workerURL: await toBlobURL(`${baseURL}/ffmpeg-core.worker.js`, "text/javascript"),
      });
      
      ffmpeg.on("log", ({ message }) => {
        setMessage(prev => `${prev}\n${message}`);
      });
      
      ffmpegRef.current = ffmpeg;
      setMessage("ffmpeg加载完成,准备就绪!");
      return ffmpeg;
    } catch (error) {
      setMessage(`加载失败: ${error instanceof Error ? error.message : String(error)}`);
      throw error;
    } finally {
      setIsLoading(false);
    }
  };

  // 处理文件上传
  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;
    
    const ffmpeg = await initFFmpeg();
    if (!ffmpeg) return;
    
    setIsLoading(true);
    setMessage(`开始处理: ${file.name}`);
    
    try {
      // 写入输入文件
      await ffmpeg.writeFile("input", await fetchFile(file));
      
      // 执行转换命令
      // -i: 输入文件
      // -c:v: 视频编码器
      // -c:a: 音频编码器
      // output.mp4: 输出文件
      await ffmpeg.exec([
        "-i", "input",
        "-c:v", "libx264",
        "-c:a", "aac",
        "output.mp4"
      ]);
      
      // 读取输出文件
      const data = await ffmpeg.readFile("output.mp4");
      const blob = new Blob([data.buffer], { type: "video/mp4" });
      const url = URL.createObjectURL(blob);
      
      setOutputUrl(url);
      setMessage("转换成功!");
    } catch (error) {
      setMessage(`处理失败: ${error instanceof Error ? error.message : String(error)}`);
    } finally {
      setIsLoading(false);
      // 重置文件输入
      if (fileInputRef.current) {
        fileInputRef.current.value = "";
      }
    }
  };

  return (
    <div className="converter">
      <h3>视频格式转换</h3>
      <input
        type="file"
        accept="video/*"
        onChange={handleFileChange}
        ref={fileInputRef}
        disabled={isLoading}
      />
      
      <div className="status">
        <p>{message}</p>
      </div>
      
      {outputUrl && (
        <div className="output">
          <h4>转换结果:</h4>
          <video 
            src={outputUrl} 
            controls 
            className="output-video"
          />
        </div>
      )}
    </div>
  );
};

export default VideoConverter;

视频剪辑功能

接下来我们实现视频剪辑功能,允许用户选择视频的起始和结束时间,提取视频的一部分。

// src/components/VideoTrimmer.tsx
import { useState, useRef, useEffect } from "react";
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile } from "@ffmpeg/util";

interface VideoTrimmerProps {
  ffmpeg: FFmpeg | null;
}

const VideoTrimmer = ({ ffmpeg }: VideoTrimmerProps) => {
  const [isProcessing, setIsProcessing] = useState(false);
  const [message, setMessage] = useState("");
  const [outputUrl, setOutputUrl] = useState("");
  const [videoDuration, setVideoDuration] = useState(0);
  const [startTime, setStartTime] = useState(0);
  const [endTime, setEndTime] = useState(10);
  
  const fileInputRef = useRef<HTMLInputElement>(null);
  const videoRef = useRef<HTMLVideoElement>(null);
  
  // 当视频加载完成后获取时长
  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;
    
    const handleLoadedMetadata = () => {
      setVideoDuration(Math.floor(video.duration));
      setEndTime(Math.min(10, Math.floor(video.duration)));
    };
    
    video.addEventListener("loadedmetadata", handleLoadedMetadata);
    return () => {
      video.removeEventListener("loadedmetadata", handleLoadedMetadata);
    };
  }, []);
  
  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;
    
    const video = videoRef.current;
    if (video) {
      video.src = URL.createObjectURL(file);
    }
    
    // 重置输出
    if (outputUrl) {
      URL.revokeObjectURL(outputUrl);
      setOutputUrl("");
    }
  };
  
  const trimVideo = async () => {
    if (!ffmpeg || !videoRef.current?.src) return;
    
    setIsProcessing(true);
    setMessage("开始剪辑视频...");
    
    try {
      // 从视频元素获取文件
      const response = await fetch(videoRef.current.src);
      const fileData = await response.blob();
      
      // 写入输入文件
      await ffmpeg.writeFile("input.mp4", await fetchFile(fileData));
      
      // 执行剪辑命令
      // -ss: 开始时间
      // -to: 结束时间
      // -c:v: 视频编码器,copy表示直接复制,不重新编码
      // -c:a: 音频编码器,copy表示直接复制
      await ffmpeg.exec([
        "-i", "input.mp4",
        "-ss", startTime.toString(),
        "-to", endTime.toString(),
        "-c:v", "copy",
        "-c:a", "copy",
        "trimmed.mp4"
      ]);
      
      // 读取输出文件
      const data = await ffmpeg.readFile("trimmed.mp4");
      const blob = new Blob([data.buffer], { type: "video/mp4" });
      const url = URL.createObjectURL(blob);
      
      setOutputUrl(url);
      setMessage("视频剪辑成功!");
    } catch (error) {
      setMessage(`剪辑失败: ${error instanceof Error ? error.message : String(error)}`);
    } finally {
      setIsProcessing(false);
    }
  };
  
  return (
    <div className="trimmer">
      <h3>视频剪辑</h3>
      
      <div className="input-section">
        <input
          type="file"
          accept="video/*"
          onChange={handleFileChange}
          ref={fileInputRef}
          disabled={isProcessing}
        />
        
        <video 
          ref={videoRef} 
          controls 
          className="source-video"
          style={{ maxWidth: "100%", marginTop: "10px" }}
        />
      </div>
      
      {videoRef.current?.src && (
        <div className="trim-controls">
          <div className="time-inputs">
            <div>
              <label>开始时间 (秒):</label>
              <input
                type="number"
                value={startTime}
                onChange={(e) => setStartTime(Math.max(0, parseInt(e.target.value) || 0))}
                min={0}
                max={endTime - 1}
                disabled={isProcessing}
              />
            </div>
            
            <div>
              <label>结束时间 (秒):</label>
              <input
                type="number"
                value={endTime}
                onChange={(e) => setEndTime(Math.min(videoDuration, parseInt(e.target.value) || 0))}
                min={startTime + 1}
                max={videoDuration}
                disabled={isProcessing}
              />
            </div>
          </div>
          
          <button onClick={trimVideo} disabled={isProcessing}>
            {isProcessing ? "剪辑中..." : "剪辑视频"}
          </button>
        </div>
      )}
      
      <div className="status">
        <p>{message}</p>
      </div>
      
      {outputUrl && (
        <div className="output">
          <h4>剪辑结果:</h4>
          <video 
            src={outputUrl} 
            controls 
            className="output-video"
            style={{ maxWidth: "100%", marginTop: "10px" }}
          />
        </div>
      )}
    </div>
  );
};

export default VideoTrimmer;

性能优化技巧

使用多线程版本

ffmpeg.wasm提供了单线程(core)和多线程(core-mt)两个版本。多线程版本利用Web Worker和SharedArrayBuffer,可以显著提高处理速度。我们已经在前面的例子中使用了多线程版本:

// 使用多线程版本
const baseURL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@0.12.10/dist/esm";

进度条实现

添加进度条可以提升用户体验,让用户了解当前处理进度:

// 添加进度监听
ffmpeg.on("progress", ({ progress, time }) => {
  const percent = Math.round(progress * 100);
  setProgress(percent);
  setMessage(`处理中: ${percent}% (${time.toFixed(2)}s)`);
});

内存管理

及时释放不需要的资源,避免内存泄漏:

// 组件卸载时终止FFmpeg实例
useEffect(() => {
  return () => {
    if (ffmpegRef.current) {
      ffmpegRef.current.terminate();
      ffmpegRef.current = null;
    }
    
    // 释放URL对象
    if (outputUrl) {
      URL.revokeObjectURL(outputUrl);
    }
  };
}, [outputUrl]);

完整应用集成

现在,我们将前面创建的组件集成到主应用中,创建一个完整的视频编辑应用:

// src/App.tsx
import { useState, useRef, useEffect } from "react";
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { toBlobURL } from "@ffmpeg/util";
import VideoConverter from "./components/VideoConverter";
import VideoTrimmer from "./components/VideoTrimmer";
import "./App.css";

function App() {
  const [isFFmpegLoaded, setIsFFmpegLoaded] = useState(false);
  const [loadingMessage, setLoadingMessage] = useState("");
  const [activeTab, setActiveTab] = useState("convert");
  const ffmpegRef = useRef<FFmpeg | null>(null);

  // 初始化FFmpeg
  const initFFmpeg = async () => {
    if (ffmpegRef.current) return ffmpegRef.current;
    
    const ffmpeg = new FFmpeg();
    setLoadingMessage("加载ffmpeg核心库...");
    
    try {
      // 使用国内CDN加载ffmpeg核心
      const baseURL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@0.12.10/dist/esm";
      
      await ffmpeg.load({
        coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
        wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"),
        workerURL: await toBlobURL(`${baseURL}/ffmpeg-core.worker.js`, "text/javascript"),
      });
      
      ffmpegRef.current = ffmpeg;
      setIsFFmpegLoaded(true);
      setLoadingMessage("");
      return ffmpeg;
    } catch (error) {
      setLoadingMessage(`加载失败: ${error instanceof Error ? error.message : String(error)}`);
      throw error;
    }
  };

  // 应用加载时初始化FFmpeg
  useEffect(() => {
    initFFmpeg();
  }, []);

  // 清理资源
  useEffect(() => {
    return () => {
      if (ffmpegRef.current) {
        ffmpegRef.current.terminate();
        ffmpegRef.current = null;
      }
    };
  }, []);

  return (
    <div className="App">
      <header className="app-header">
        <h1>浏览器视频编辑工具</h1>
        <p>基于ffmpeg.wasm和React构建</p>
      </header>
      
      {loadingMessage && (
        <div className="loading-status">{loadingMessage}</div>
      )}
      
      {isFFmpegLoaded && (
        <div className="app-content">
          <div className="tab-buttons">
            <button 
              className={activeTab === "convert" ? "active" : ""}
              onClick={() => setActiveTab("convert")}
            >
              格式转换
            </button>
            <button 
              className={activeTab === "trim" ? "active" : ""}
              onClick={() => setActiveTab("trim")}
            >
              视频剪辑
            </button>
          </div>
          
          <div className="tab-content">
            {activeTab === "convert" && <VideoConverter />}
            {activeTab === "trim" && <VideoTrimmer ffmpeg={ffmpegRef.current} />}
          </div>
        </div>
      )}
      
      <footer className="app-footer">
        <p>所有视频处理均在本地完成,保护您的隐私</p>
      </footer>
    </div>
  );
}

export default App;

部署与优化建议

构建优化

在构建生产版本时,可以通过以下方式减小包体积:

# 构建生产版本
npm run build

# 分析包体积
npx source-map-explorer dist/assets/*.js

部署注意事项

  1. CORS设置:确保服务器正确设置了CORS头,特别是对于WASM文件
  2. CDN使用:使用国内CDN加速ffmpeg.wasm核心文件的加载,如示例中使用的jsdelivr
  3. 加载策略:考虑使用懒加载策略,只在用户需要视频编辑功能时才加载ffmpeg.wasm

性能监控

添加性能监控代码,了解视频处理耗时:

// 记录处理时间
const start = performance.now();
// 执行ffmpeg命令
await ffmpeg.exec([...]);
const end = performance.now();
setMessage(`处理完成,耗时: ${((end - start) / 1000).toFixed(2)}秒`);

总结与扩展

通过本文的介绍,我们学习了如何使用ffmpeg.wasm和React构建浏览器端视频编辑组件。我们实现了视频格式转换和剪辑功能,并探讨了性能优化和内存管理的技巧。

这个基础框架可以进一步扩展,添加更多高级功能:

  • 视频滤镜和特效
  • 音频处理功能
  • 多轨道编辑
  • 视频水印添加

ffmpeg.wasm的强大之处在于它将完整的FFmpeg功能带到了浏览器中,使我们能够构建功能丰富的客户端媒体处理应用,而无需后端支持。

完整的项目代码可以在apps/react-vite-app目录中找到,包含了更多示例和最佳实践。

希望本文能帮助你快速上手浏览器端视频处理开发,创造出令人惊艳的媒体应用!

【免费下载链接】ffmpeg.wasm FFmpeg for browser, powered by WebAssembly 【免费下载链接】ffmpeg.wasm 项目地址: https://gitcode.com/gh_mirrors/ff/ffmpeg.wasm

Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐