AI 探索技术手记

FFmpeg WASM 浏览器端视频合成:从图片序列到 MP4 的前端实现

2026年7月23日◷ 5 分钟阅读
FFmpeg WASM 浏览器端视频合成:从图片序列到 MP4 的前端实现

概述

传统视频合成依赖后端 FFmpeg 命令行,用户需要上传图片、等待处理、再下载,整个流程重且延迟高。利用 @ffmpeg/ffmpeg 的 WebAssembly 编译版本,我们可以在浏览器中直接将图片序列合成为 MP4 视频,全程客户端完成,零服务端开销。本文覆盖:FFmpeg WASM 初始化、图片加载、合成参数控制(帧率/时长/分辨率)、进度回调、以及前端 vs 后端方案对比。

1. FFmpeg WASM 技术原理

FFmpeg 是 C/C++ 写的音视频处理库,通过 Emscripten 编译为 WebAssembly,可以在浏览器中运行。

C/C++ FFmpeg 源码
       ↓ Emscripten
   FFmpeg.wasm
       ↓ 浏览器加载
  JavaScript FFmpeg API
       ↓
 虚拟文件系统 (MEMFS)
       ↓
 创建图片序列 → 合成 → 输出 MP4 → ArrayBuffer → Blob → 下载

关键限制:
- WASM 版本功能为完整版子集(不包含所有编解码器)
- 内存受浏览器限制,不适合超长视频(建议 < 2 分钟)
- 首次加载需下载 ~31MB 的 WASM 文件

2. 安装与初始化

npm install @ffmpeg/ffmpeg @ffmpeg/util
// ===== ffmpeg-engine.ts =====
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile, toBlobURL } from '@ffmpeg/util';

let ffmpeg: FFmpeg | null = null;
let isLoaded = false;

const BASE_URL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';

export async function getFFmpeg(): Promise<FFmpeg> {
  if (ffmpeg && isLoaded) return ffmpeg;

  ffmpeg = new FFmpeg();

  ffmpeg.on('log', ({ message }) => {
    console.log('[FFmpeg]', message);
  });

  ffmpeg.on('progress', ({ progress, time }) => {
    console.log(`[FFmpeg] ${Math.round(progress * 100)}% done`);
  });

  await ffmpeg.load({
    coreURL: await toBlobURL(`${BASE_URL}/ffmpeg-core.js`, 'text/javascript'),
    wasmURL: await toBlobURL(`${BASE_URL}/ffmpeg-core.wasm`, 'application/wasm'),
  });

  isLoaded = true;
  return ffmpeg;
}

3. 图片序列合成视频

// ===== compose-video.ts =====
import { getFFmpeg } from './ffmpeg-engine';
import { fetchFile } from '@ffmpeg/util';

interface ComposeOptions {
  images: string[];        // 图片 URL 或 Base64 数组
  fps?: number;             // 帧率,默认 24
  outputWidth?: number;     // 输出宽度
  outputHeight?: number;    // 输出高度
  onProgress?: (percent: number) => void;
  crf?: number;             // 质量 (0-51, 越小越好),默认 23
}

export async function composeImagesToVideo(options: ComposeOptions): Promise<Blob> {
  const { images, fps = 24, outputWidth = 1280, outputHeight = 720, onProgress, crf = 23 } = options;

  if (images.length < 2) {
    throw new Error('至少需要 2 张图片');
  }

  const ffmpeg = await getFFmpeg();

  // === 步骤 1: 将所有图片写入 FFmpeg 虚拟文件系统 ===
  for (let i = 0; i < images.length; i++) {
    const data = await fetchFile(images[i]);
    // 文件名格式:img0001.png, img0002.png ...
    const paddedIndex = String(i + 1).padStart(4, '0');
    await ffmpeg.writeFile(`img${paddedIndex}.png`, data);
    onProgress?.(Math.round((i / images.length) * 30));
  }

  // === 步骤 2: 统一尺寸 + 编码为视频流 ===
  // 先把所有图片 resize 成统一尺寸,再合成视频
  const args = [
    '-framerate', String(fps),
    '-i', 'img%04d.png',               // 输入序列
    '-vf', `scale=${outputWidth}:${outputHeight}:force_original_aspect_ratio=decrease,pad=${outputWidth}:${outputHeight}:(ow-iw)/2:(oh-ih)/2`,
    '-c:v', 'libx264',                  // H.264 编码
    '-crf', String(crf),                // 质量
    '-pix_fmt', 'yuv420p',              // 兼容性最好的像素格式
    '-preset', 'medium',                 // 编码速度
    '-movflags', '+faststart',          // 支持流式播放
    '-y',
    'output.mp4',
  ];

  // 进度监听
  ffmpeg.on('progress', ({ progress }) => {
    onProgress?.(30 + Math.round(progress * 70));
  });

  await ffmpeg.exec(args);

  // === 步骤 3: 读取输出文件 ===
  const data = await ffmpeg.readFile('output.mp4');
  const blob = new Blob([(data as Uint8Array).buffer], { type: 'video/mp4' });

  // === 步骤 4: 清理虚拟文件系统 ===
  for (let i = 0; i < images.length; i++) {
    const paddedIndex = String(i + 1).padStart(4, '0');
    await ffmpeg.deleteFile(`img${paddedIndex}.png`);
  }
  await ffmpeg.deleteFile('output.mp4');

  return blob;
}

4. React 组件:图片选取 → 合成 → 下载

// ===== VideoComposer.tsx =====
import React, { useState } from 'react';
import { composeImagesToVideo } from './compose-video';

export default function VideoComposer() {
  const [selectedImages, setSelectedImages] = useState<string[]>([]);
  const [progress, setProgress] = useState(0);
  const [status, setStatus] = useState<'idle' | 'composing' | 'done' | 'error'>('idle');
  const [videoUrl, setVideoUrl] = useState('');

  const handleCompose = async () => {
    if (selectedImages.length < 2) {
      alert('至少选择 2 张图片');
      return;
    }

    setStatus('composing');
    setProgress(0);

    try {
      const blob = await composeImagesToVideo({
        images: selectedImages,
        fps: 12,
        outputWidth: 1280,
        outputHeight: 720,
        onProgress: setProgress,
      });

      const url = URL.createObjectURL(blob);
      setVideoUrl(url);
      setStatus('done');
    } catch (err: any) {
      console.error(err);
      setStatus('error');
    }
  };

  const handleDownload = () => {
    const a = document.createElement('a');
    a.href = videoUrl;
    a.download = `composed_${Date.now()}.mp4`;
    a.click();
  };

  return (
    <div className="video-composer">
      <h2>图片合成视频</h2>

      {/* 图片选择 */}
      <input
        type="file"
        accept="image/*"
        multiple
        onChange={(e) => {
          const files = Array.from(e.target.files || []);
          Promise.all(
            files.map((f) => new Promise<string>((resolve) => {
              const reader = new FileReader();
              reader.onload = () => resolve(reader.result as string);
              reader.readAsDataURL(f);
            }))
          ).then(setSelectedImages);
        }}
      />

      {/* 已选图片预览网格 */}
      {selectedImages.length > 0 && (
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))', gap: 8, marginTop: 12 }}>
          {selectedImages.map((img, i) => (
            <img key={i} src={img} alt={`选中 ${i + 1}`} style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 4 }} />
          ))}
        </div>
      )}

      <p>已选: {selectedImages.length} 张</p>

      {/* 合成 */}
      <button onClick={handleCompose} disabled={status === 'composing'}>
        {status === 'composing' ? `合成中... ${progress}%` : '合成视频'}
      </button>

      {/* 进度 */}
      {status === 'composing' && (
        <div className="progress-bar-bg">
          <div className="progress-bar-fill" style={{ width: `${progress}%` }} />
        </div>
      )}

      {/* 结果 */}
      {status === 'done' && (
        <div className="result">
          <video src={videoUrl} controls style={{ width: '100%', maxWidth: 480 }} />
          <button onClick={handleDownload}>下载视频</button>
        </div>
      )}

      {status === 'error' && <p style={{ color: 'red' }}>合成失败,请重试</p>}
    </div>
  );
}

5. 前端 WASM vs 后端方案对比

维度 前端 FFmpeg WASM 后端 FFmpeg
部署 无需服务端 需要服务器 + 安装 FFmpeg
加载 首次 ~31MB WASM
性能 受浏览器内存限制 无限制
适用场景 少量图片、短视频 大量图片、长视频、高清输出
离线能力 完全离线可用 需要网络
稳定性 浏览器差异 生产级稳定

选型原则:小文件/轻量场景用前端 WASM(无需后端、即合即下),大文件/生产环境用后端合成(稳、快、内存不受限)。

6. 多媒体素材管理扩展

与视频合成配套的素材管理模块:

interface MediaItem {
  id: string;
  type: 'image' | 'video';
  url: string;
  createTime: number;
}

// 关键设计:
// 1. 网格式展示 - CSS Grid 自适应列数
// 2. selectedIds 数组管理选中状态(不给每个 item 加 selected,性能更好)
// 3. 批量操作:删除、下载、合成视频
// 4. 双击全屏预览(图片用 img,视频用 video + controls)
// 5. 合成后的视频自动加入素材库

面试核心要点

问:FFmpeg WASM 是怎么在浏览器里跑的?

FFmpeg 是 C/C++ 写的,通过 Emscripten 编译为 WebAssembly 字节码。浏览器加载 .wasm 文件后,JS 通过 FFmpeg API 操作虚拟文件系统(MEMFS):把图片 writeFile 进去,exec 命令行参数,readFile 拿出 MP4。

问:大量素材如何优化?

  1. 虚拟列表(react-window)只渲染可视区域
  2. 图片懒加载(IntersectionObserver)
  3. 缩略图使用压缩版本
  4. 选中状态用数组管理
  5. 大量数据使用 IndexedDB 存储

问:图片合成视频,前端做还是后端做?

小文件用前端 WASM(轻量、离线、零服务端成本),大文件用后端合成(稳定、不受浏览器内存限制)。实际项目中我会根据图片数量、分辨率和服务端资源灵活选择。

总结

  • FFmpeg WASM 把 C 语言的音视频处理能力搬进浏览器
  • 虚拟文件系统 是核心抽象:writeFile → exec → readFile
  • 合成流程:图片序列 → resize 统一尺寸 → H.264 编码 → MP4 输出
  • 前端方案 适合少量/轻量场景,后端方案适合生产级处理
  • 多媒体素材管理需配套网格展示、批量操作、全屏预览

Comments 留言讨论

还没有评论,来抢个沙发,聊聊你的看法~

Michael.Meng

michaelnews@126.com
用 AI 记录,用文字沉淀

© 2026 Michael Meng · 保留所有权利 · Powered by FastAPI + Nuxt