pc端视频压缩:FFmpeg.wasm 实战指南

为什么需要浏览器端压缩

用户上传视频时,直接上传原始文件有几个问题:上传慢、存储贵、播放卡。在浏览器端先压缩再上传,可以减少 50% 以上的上传量和存储空间。

技术方案

依赖

kotlin 复制代码
npm install @ffmpeg/ffmpeg@0.12.15 @ffmpeg/util@0.12.2

注意用 0.12.x 版本,0.11.x 的 API 完全不同,网上很多旧教程的写法会报错。

为什么不用 Canvas + MediaRecorder

Canvas + MediaRecorder 零依赖,但有两个硬伤:输出格式主要是 WebM(兼容性差),无法精确控制码率和分辨率。FFmpeg.wasm 是专业方案,输出 MP4/H.264,参数完全可控。

Vite 配置

php 复制代码
export default defineConfig({
  optimizeDeps: {
    exclude: ['@ffmpeg/ffmpeg', '@ffmpeg/util'],
  },
  server: {
    headers: {
      'Cross-Origin-Opener-Policy': 'same-origin',
      'Cross-Origin-Embedder-Policy': 'require-corp',
    },
  },
});

exclude 是为了避免 Vite 预构建处理 wasm 文件。两个 headers 是多线程模式需要的,后面会解释。

核心实现

加载 FFmpeg

FFmpeg.wasm 首次使用需要从 CDN 加载核心文件(约 31MB),加载完成后缓存在浏览器中。

这里有个关键点:FFmpeg.wasm 有两个版本------单线程版(@ffmpeg/core)和多线程版(@ffmpeg/core-mt)。多线程版快 2-4 倍,但需要 SharedArrayBuffer 支持。

javascript 复制代码
async function loadFFmpeg(): Promise<FFmpeg> {
  const ffmpeg = new FFmpeg();

  // 检测 SharedArrayBuffer 是否可用
  const isSharedArrayBufferSupported = typeof SharedArrayBuffer !== 'undefined';

  let loaded = false;

  // 先尝试多线程版本
  if (isSharedArrayBufferSupported) {
    try {
      const mtBaseURL = 'https://unpkg.com/@ffmpeg/core-mt@0.12.6/dist/esm';
      await ffmpeg.load({
        coreURL: `${mtBaseURL}/ffmpeg-core.js`,
        wasmURL: `${mtBaseURL}/ffmpeg-core.wasm`,
        workerURL: `${mtBaseURL}/ffmpeg-core.worker.js`,
      });
      loaded = true;
    } catch (error) {
      // 多线程加载失败,降级到单线程
      console.warn('多线程加载失败,降级单线程', error);
    }
  }

  // 单线程兜底
  if (!loaded) {
    const stBaseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
    await ffmpeg.load({
      coreURL: `${stBaseURL}/ffmpeg-core.js`,
      wasmURL: `${stBaseURL}/ffmpeg-core.wasm`,
    });
  }

  return ffmpeg;
}

一定要做降级处理。多线程版本可能因为网络、CORS 等原因加载失败,如果不降级就直接报错了。

获取视频信息

压缩前需要知道视频的分辨率和帧率,用来决定是否需要降分辨率或降帧率。

ini 复制代码
function getVideoInfo(file: File): Promise<VideoInfo> {
  return new Promise((resolve, reject) => {
    const video = document.createElement('video');
    video.preload = 'metadata';
    video.muted = true;
    const blobUrl = URL.createObjectURL(file);
    let isResolved = false;

    // 统一清理函数
    const cleanup = () => {
      if (!isResolved) {
        isResolved = true;
        URL.revokeObjectURL(blobUrl);
        video.src = '';
        video.load();
      }
    };

    video.onloadedmetadata = () => {
      const width = video.videoWidth;
      const height = video.videoHeight;
      const duration = video.duration;

      // 通过 requestVideoFrameCallback 获取帧率
      // 不支持则默认 30fps
      // ...帧率检测代码...

      // 所有操作完成后调用 cleanup()
    };

    video.onerror = () => {
      cleanup();
      reject(new Error('无法获取视频信息'));
    };

    video.src = blobUrl;
  });
}

这里有个容易踩的坑:URL.revokeObjectURL 不能在 onloadedmetadata 里立即调用。因为帧率检测需要视频继续加载帧,提前释放 blob URL 会导致 ERR_FILE_NOT_FOUND 错误。必须等所有操作完成后再释放。

执行压缩

javascript 复制代码
async function compressVideo(
  file: File,
  ffmpeg: FFmpeg,
  onProgress?: (progress: CompressProgress) => void
): Promise<CompressResult> {
  // 写入输入文件
  const inputFileName = 'input' + file.name.substring(file.name.lastIndexOf('.'));
  await ffmpeg.writeFile(inputFileName, await fetchFile(file));

  // 构建 FFmpeg 命令
  const args = [
    '-i', inputFileName,

    // 视频滤镜(如果需要降分辨率或降帧率)
    '-vf', 'scale=iw*0.5:ih*0.5,fps=30',

    // 编码参数
    '-c:v', 'libx264',       // H.264 编码
    '-crf', '28',             // 恒定质量模式
    '-preset', 'ultrafast',   // 最快编码速度
    '-tune', 'zerolatency',   // 禁用前瞻优化
    '-c:a', 'copy',           // 直接复制音频
    '-movflags', '+faststart',// 优化流式播放
    '-y', 'output.mp4'
  ];

  // 注册进度回调(压缩完成后必须移除)
  const handler = ({ progress, time }: any) => {
    const percent = Math.round(10 + progress * 90);
    onProgress?.({ percent, ... });
  };
  ffmpeg.on('progress', handler);

  try {
    await ffmpeg.exec(args);
  } finally {
    ffmpeg.off('progress', handler);  // 必须移除!
  }

  // 读取输出文件
  const data = await ffmpeg.readFile('output.mp4');

  // 清理临时文件
  await ffmpeg.deleteFile(inputFileName);
  await ffmpeg.deleteFile('output.mp4');

  return new File([data], 'compressed.mp4', { type: 'video/mp4' });
}

编码参数怎么选

CRF:控制画质和压缩率的平衡

CRF(Constant Rate Factor)是 H.264 的恒定质量模式。值越大,压缩率越高,画质越低。

CRF 画质 压缩率 适合场景
18 视觉无损 20-30% 对画质要求极高
23 默认质量 40-60% 一般场景
28 差异极小 50-70% 浏览器端推荐
32 有明显损失 60-80% 对体积极度敏感

CRF 每增加 6,码率约减少一半。浏览器端推荐 28,正常观看距离下与原始视频几乎无法区分。

preset:控制编码速度

preset 速度 压缩率 适合场景
ultrafast 最快 差 10-15% 浏览器端推荐
fast 差 5-10% 一般场景
medium 中等 基准 原生 FFmpeg 默认
slow 好 5% 离线处理

浏览器中 FFmpeg.wasm 性能只有原生的 20-40%,用 medium 太慢了。ultrafast 虽然压缩率低一点,但速度快 5-10 倍,在浏览器场景下是值得的。

三个提速技巧

1. -tune zerolatency

禁用前瞻分析、码率预测等耗时优化。这是为实时编码设计的模式,提速约 30-50%,画质损失很小。

2. -c:a copy

直接复制音频流,不重新编码。大多数视频的音频已经是 AAC 格式,没必要重新编码。提速约 15-20%。

注意:如果源视频音频不是 AAC(比如 MP3 或 Vorbis),-c:a copy 可能导致输出文件播放异常,需要改回 -c:a aac -b:a 128k

3. -preset ultrafast

前面说了,比 medium 快 5-10 倍。

三个组合起来,100MB 视频从原来 5-7 分钟缩短到 1-2 分钟。

多线程模

多线程模式理论上快 2-4倍,但是我没有成功,即使我本身本地开发之后还是存在压缩时报错:

javascript 复制代码
TypeError: Cannot read properties of undefined (reading 'startsWith')

要使用多线程需要满足以下条件。

条件一:浏览器支持 SharedArrayBuffer

Chrome 92+、Firefox 79+ 支持。检测方式:

javascript 复制代码
typeof SharedArrayBuffer !== 'undefined'

条件二:服务器返回 COOP/COEP 响应头

makefile 复制代码
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Vite 开发服务器配置:

css 复制代码
server: {
  headers: {
    'Cross-Origin-Opener-Policy': 'same-origin',
    'Cross-Origin-Embedder-Policy': 'require-corp',
  },
}

条件三:访问地址必须是"可信源"

这是最容易忽略的一点。根据 W3C 规范,COOP/COEP 头只在可信源上生效。如果访问地址不可信,浏览器会直接忽略这些头,控制台会打印警告:

css 复制代码
The Cross-Origin-Opener-Policy header has been ignored,
because the URL's origin was untrustworthy.
访问方式 是否可信 多线程
http://localhost:3000 可用
http://127.0.0.1:3000 可用
https://example.com 可用
http://192.168.x.x:3000 不可用

开发时用 localhost 访问,不要用局域网 IP。生产环境用 HTTPS。

实际开发的过程中我们可能会从各种问答担心一个问题。如果视频过大压缩过程中浏览器会不会崩溃

实际上存在这种可能,但是我们这里需要考虑电脑本身的内存的大小。

内存占用分析

FFmpeg.wasm 压缩视频时,内存占用约为文件大小的 3 倍:

阶段 内存占用
原始文件读入 ArrayBuffer ≈ 文件大小
FFmpeg 虚拟文件系统 ≈ 文件大小
编码工作内存(解码帧+编码帧) ≈ 文件大小 × 0.5
输出文件 ≈ 文件大小 × 0.5

所以 1GB 视频压缩约需 3GB 内存。

关键因素:电脑物理内存

浏览器能分配多少内存,很大程度取决于电脑的物理内存大小:

电脑内存 能压缩的最大视频 说明
4GB ~200MB 系统和浏览器本身占 2-3GB,剩余不多
8GB ~500MB 比较宽裕
16GB ~1GB 足够处理大多数视频
32GB+ ~1.5GB 接近浏览器单标签页上限

注意,即使电脑内存很大,浏览器单个标签页的内存上限也大约是 4GB(64 位 Chrome)。这是浏览器的安全限制,不是系统能给多少就用多少。

建议的阈值

视频大小 处理方式
< 50MB 不压缩,直接上传
50MB - 500MB 前端压缩,安全
500MB - 1GB 前端压缩,但提示用户可能较慢
> 1GB 禁止前端压缩,使用后端转码

超过 1GB 的视频,建议直接上传原始文件,后端用原生 FFmpeg 转码。原生 FFmpeg 速度比浏览器快 10-20 倍,而且没有内存限制。

常见问题

控制台持续打印 ERR_FILE_NOT_FOUND

原因:URL.revokeObjectURL 过早调用,视频元素还在使用该 blob URL。

解决:等所有异步操作完成后再释放 blob URL。

压缩完成后日志还在打印

原因:FFmpeg 实例是单例,ffmpeg.on('progress', ...) 注册后不移除,每次压缩都重复注册。

解决:压缩完成后用 ffmpeg.off('progress', handler) 移除监听器。

压缩后视频没有声音

原因:-c:a copy 直接复制音频流,如果源视频音频不是 AAC 格式,输出文件可能播放异常。

解决:改用 -c:a aac -b:a 128k 重新编码音频。

首次压缩很慢

原因:首次使用需要从 CDN 加载约 31MB 的核心文件。

解决:加载完成后会缓存在浏览器中,后续使用不需要重复加载。可以把核心文件部署到自己的 CDN 上提升加载速度。

总结

浏览器端视频压缩是可行的,但有几个要点:

  1. 用 0.12.x 版本,API 和 0.11.x 不同
  2. 0.12.x 多线程版本有 bug,当前只用单线程
  3. blob URL 延迟释放,避免 ERR_FILE_NOT_FOUND
  4. 事件监听器压缩完必须移除
  5. ultrafast + zerolatency + copy 三板斧提速
  6. 大文件看电脑内存,超过 1GB 用后端转码
相关推荐
0x861 小时前
基于 Dio 实现 SSE 流式通信
前端
ZC跨境爬虫1 小时前
跟着 MDN 学 HTML day_40:(DOMImplementation 接口完全解析)
前端·ui·html·媒体
Highcharts.js1 小时前
Highcharts 纯 JavaScript 图表库深度使用评测
开发语言·前端·javascript·功能测试·ecmascript·highcharts·技术评测
码码哈哈0.01 小时前
基于 RSA 非对称加密与挑战码机制的前端登录安全方案
前端·安全·状态模式
ZC跨境爬虫1 小时前
跟着 MDN 学 HTML day_39:(DOMException 异常接口完全解析)
前端·javascript·html·媒体
渐儿2 小时前
NestJS 教程 Part 2 — 数据层、API 设计与业务异步
前端
渐儿2 小时前
Next.js 教程 Part 2 — 数据获取、Server Actions 与状态
前端
用户125758524362 小时前
XYGo Admin ArtTable 表格组件:一行代码搞定加载、刷新与分页
前端