Skip to content

浏览器端视频片段转 GIF / 动态WebP 开发实践踩坑记录

更新: 6/13/2026 字数: 0 字 时长: 0 分钟

这份文档记录的是一个真实的踩坑过程:在 VitePress 项目里做一个本地视频转动图的组件,从「引擎一直加载不出来」一路排到「ESM 版本 + curl 下载到 public」才跑通。中间踩的坑都在,不是事后整理的理想路径。

技术栈:VitePress(底层是 Vite)+ Vue 3 + FFmpeg.wasm。核心结论:core 文件必须用 ESM 版,用 curl 下到 public 目录本地加载;多线程还要补 worker 文件,并且把跨域隔离头加到 .vitepress/config 而不是项目根的 vite.config.js

浏览器端视频转 GIF/WebP 整体流程

一、开发前置准备

1.1 依赖安装

应用层两个包正常走 npm 打包,它们是你代码的一部分:

bash
npm install @ffmpeg/ffmpeg @ffmpeg/util

core(真正干活的 wasm 内核)不要让 Vite 打包,后面单独用 curl 下到 public。版本要和 @ffmpeg/ffmpeg 对应,这里统一用 0.12.6

1.2 单线程还是多线程,先定下来

这一步定不下来,后面会反复返工。两套配置不能混用:

npm 包public 需要的文件load() 参数是否需要 COOP/COEP
单线程@ffmpeg/corejs + wasmcoreURL + wasmURL不需要
多线程@ffmpeg/core-mtjs + wasm + worker.jscoreURL + wasmURL + workerURL需要

多线程快,但要开跨域隔离、要多管一个 worker 文件;单线程慢一点,但零隔离配置,文档站场景更省心。这次实践最终走的是多线程版,所以下面命令以多线程为主,单线程的差异会标出来。

二、踩坑复盘

这部分是这次开发的核心价值,按我实际遇到的顺序排。

引擎加载卡死的踩坑排查

坑 1:引擎一直「加载中」,Console 不报错

现象:页面按钮一直显示「引擎加载中」,ffmpeg.load() 既不 resolve 也不 reject,Console 干净得很。

第一反应是文件没下下来,但 Network 里 core 的 js 和 wasm 都是 200。这说明文件没问题,卡在 load() 内部。

定位手段是这一行:

js
console.log(self.crossOriginIsolated)  // 返回 false

返回 false,根因就清楚了:core 是多线程版,内部在等 SharedArrayBuffer,而页面没进入跨域隔离状态,worker 起不来,主线程一直等握手。它不会报错,只会一直挂着。

坑 2:跨域隔离头加错了文件

加了 vite.config.js 的响应头,crossOriginIsolated 还是 false。原因是 VitePress 不读项目根目录的 vite.config.js,它的 Vite 配置要写在 .vitepress/config 里。

正确位置:

js
// .vitepress/config.js
import { defineConfig } from 'vitepress'

export default defineConfig({
  vite: {
    server: {
      headers: {
        'Cross-Origin-Opener-Policy': 'same-origin',
        'Cross-Origin-Embedder-Policy': 'require-corp',
      },
    },
  },
})

改完必须完全重启 dev server,header 改动不重启不生效。验证方法:F12 → Network → 点 HTML 文档请求(比如 work.html)→ 看 Response Headers 里有没有这两行,再跑一次 self.crossOriginIsolated 确认变成 true

坑 3:optimizeDeps 预构建和 ffmpeg 内部 worker 冲突

隔离开了之后,冒出一条黄色警告:

The file does not exist at ".../cache/deps/worker.js?worker_file&type=module"
which is in the optimize deps directory.

这是 Vite 的依赖预优化器处理不了 @ffmpeg/ffmpeg 内部的 new Worker(new URL(...)) 引用。解决办法是把 ffmpeg 两个包排除出预构建:

js
// .vitepress/config.js → vite 配置内
optimizeDeps: {
  exclude: ['@ffmpeg/ffmpeg', '@ffmpeg/util'],
},

改完删缓存再重启,确保 exclude 生效:

bash
rm -rf docs/.vitepress/cache
npm run docs:dev

这个 worker.js@ffmpeg/ffmpeg 包自己的 worker,不是 core 的 ffmpeg-core.worker.js,两个别搞混。

坑 4:failed to import ffmpeg-core.js(最终的关键坑)

隔离和预构建都过了之后,报了一条明确的错:failed to import ffmpeg-core.js

根因是 core 文件用了 UMD 版@ffmpeg/ffmpeg@0.12 内部是用 import() 动态导入 core 的,而 UMD 格式不是 ES module,import() 进来直接失败。

解决就是这次实践验证过的方案:改用 ESM 版的 core 文件@ffmpeg/core 的 dist 下通常有 umd/esm/ 两个目录,要用 esm/ 里的。

三、核心实现

3.1 用 curl 把 core 资源下到 public

这次验证可行的做法是 curl 直接下载 ESM 版的 core 文件到 public/ffmpeg/(多线程版三个文件):

bash
mkdir -p public/ffmpeg

# 多线程 ESM 版核心 js
curl -o public/ffmpeg/ffmpeg-core.js \
  https://unpkg.com/@ffmpeg/core-mt@0.12.6/dist/esm/ffmpeg-core.js

# wasm 内核
curl -o public/ffmpeg/ffmpeg-core.wasm \
  https://unpkg.com/@ffmpeg/core-mt@0.12.6/dist/esm/ffmpeg-core.wasm

# 多线程 worker(单线程版不需要这个)
curl -o public/ffmpeg/ffmpeg-core.worker.js \
  https://unpkg.com/@ffmpeg/core-mt@0.12.6/dist/esm/ffmpeg-core.worker.js

单线程版把上面三条换成两条、包名换成 @ffmpeg/core@0.12.6 即可,且不需要 worker 那条。

下完务必验证:浏览器直接访问 http://localhost:5173/ffmpeg/ffmpeg-core.wasm,确认能下载、大小约 25MB(几 KB 说明下到的是错误页或 LFS 指针,不是真文件)。

VitePress 的静态资源根目录是 srcDir 下的 public/,运行时映射到站点根路径 /,所以代码里路径写 /ffmpeg/...,不带 public 前缀。

curl 下载 core 资源到 public 并用 ESM 加载

3.2 ESM 加载关键代码

onMounted 里加载引擎,多线程版三个 URL 都要给。toBlobURL 这层包装不要去掉——开了 COEP require-corp 之后,直接传路径有时会被资源跨源策略拦,转成同源 Blob URL 最稳:

js
import { FFmpeg } from '@ffmpeg/ffmpeg'
import { toBlobURL } from '@ffmpeg/util'

const ffmpeg = new FFmpeg()
const ffmpegReady = ref(false)

onMounted(async () => {
  try {
    const baseURL = '/ffmpeg' // public/ffmpeg 映射到站点根

    // 加超时兜底:把"静默卡死"变成明确报错,这是踩坑期间救命的一步
    const loadPromise = 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'), // 单线程版删掉这行
    })
    const timeout = new Promise((_, rej) =>
      setTimeout(() => rej(new Error('load 超时,检查隔离/worker')), 30000)
    )
    await Promise.race([loadPromise, timeout])

    // 进度回传,0~1 转百分比
    ffmpeg.on('progress', ({ progress: p }) => {
      progress.value = Math.round(Math.min(Math.max(p, 0), 1) * 100)
    })
    ffmpegReady.value = true
  } catch (e) {
    errorMsg.value = '引擎加载失败:' + e.message
    console.error(e)
  }
})

3.3 片段截取与编码

转换命令的核心是 -ss(起点)+ -t(时长)裁剪片段,fps 滤镜控帧率,scale 控宽度(-1 让高度按比例自适应):

js
const ss = startTime.value.toFixed(2)
const t = (endTime.value - startTime.value).toFixed(2)
const vf = `fps=${fps.value},scale=${scaleWidth.value}:-1:flags=lanczos`

await ffmpeg.writeFile('input.mp4', await fetchFile(videoFile.value))

let args
if (format.value === 'gif') {
  args = ['-ss', ss, '-t', t, '-i', 'input.mp4', '-vf', vf, '-loop', '0', 'out.gif']
} else {
  // WebP 动图用 libwebp_anim,-q:v 控质量,体积通常明显小于同等 GIF
  args = ['-ss', ss, '-t', t, '-i', 'input.mp4', '-vf', vf,
          '-c:v', 'libwebp_anim', '-loop', '0', '-q:v', '70', 'out.webp']
}
await ffmpeg.exec(args)

const data = await ffmpeg.readFile(format.value === 'gif' ? 'out.gif' : 'out.webp')
const mime = format.value === 'gif' ? 'image/gif' : 'image/webp'
const blob = new Blob([data.buffer], { type: mime })
resultUrl.value = URL.createObjectURL(blob)

四、部署验证

dev 环境跑通不等于线上能用,这里有个容易翻车的点。

部署上线配置跨域隔离并验证

  1. vite.server.headers 只对 dev 生效。 线上静态部署后,COOP/COEP 两个头要在 Nginx / CDN 上单独配,否则线上 crossOriginIsolated 又是 false,多线程引擎重新卡死。

    Nginx 示例:

    nginx
    add_header Cross-Origin-Opener-Policy   same-origin;
    add_header Cross-Origin-Embedder-Policy require-corp;
  2. vite preview 也不读 server.headers,要本地预览构建产物得用 preview.headers 配。

  3. 验证标准只有一个:线上页面 Console 跑 self.crossOriginIsolated,必须是 true;再实际转一个 3 秒小视频,能出图、能下载,才算通过。

  4. wasm 文件确认随构建产物一起发布:核心文件在 public/ffmpeg/,VitePress 构建会原样拷到产物根目录,部署时别漏掉。

五、常见问题梳理

Q1:为什么只下 wasme、js 还走 CDN 不行? core 的 js 是 wasm 的加载胶水,它要定位对应的 wasm。js 走 CDN、wasm 走本地容易版本错配;开了 COEP 之后跨域加载 js 还会被 CORP 拦。两个文件一起本地化,保持同源同版本。

Q2:crossOriginIsolated 怎么都是 false? 按概率查:① header 加错文件(VitePress 要加在 .vitepress/config);② 改完没重启;③ 不是用 dev server 访问(Live Server、双击 html 都不带头);④ 页面有不带 CORP 的跨域资源把隔离破坏了。

Q3:failed to import ffmpeg-core.js? core 用成了 UMD 版。换 dist/esm/ 下的文件,这是这次实践的关键修复点。

Q4:不想折腾隔离,有没有更省事的路? 有。换单线程 @ffmpeg/core,curl 下 ESM 版的 js + wasm 两个文件,load() 去掉 workerURL,COOP/COEP 头也可以删。代价是转换速度慢一些,中小视频完全够用,文档站场景更推荐这条。

Q5:转换很慢 / 标签页崩溃? WASM 受限于单标签页内存(一般 2–4GB),大视频、高分辨率、长片段都吃内存。建议限制片段时长(≤10s)、输出宽度(≤640px)、帧率(≤15fps),并在 catch 里给友好提示。

整条路线复盘下来,真正卡住人的不是代码逻辑,而是三件事:隔离头加对位置、core 用 ESM 版、多线程别漏 worker。这三点过了,剩下的转换逻辑都是 FFmpeg 命令的常规活儿。如果你的项目是文档站、对速度不敏感,直接走单线程能省掉前两个坑里的大部分麻烦。