Appearance
浏览器端视频片段转 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。

一、开发前置准备
1.1 依赖安装
应用层两个包正常走 npm 打包,它们是你代码的一部分:
bash
npm install @ffmpeg/ffmpeg @ffmpeg/utilcore(真正干活的 wasm 内核)不要让 Vite 打包,后面单独用 curl 下到 public。版本要和 @ffmpeg/ffmpeg 对应,这里统一用 0.12.6。
1.2 单线程还是多线程,先定下来
这一步定不下来,后面会反复返工。两套配置不能混用:
| npm 包 | public 需要的文件 | load() 参数 | 是否需要 COOP/COEP | |
|---|---|---|---|---|
| 单线程 | @ffmpeg/core | js + wasm | coreURL + wasmURL | 不需要 |
| 多线程 | @ffmpeg/core-mt | js + wasm + worker.js | coreURL + 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前缀。

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 环境跑通不等于线上能用,这里有个容易翻车的点。

vite.server.headers只对 dev 生效。 线上静态部署后,COOP/COEP 两个头要在 Nginx / CDN 上单独配,否则线上crossOriginIsolated又是false,多线程引擎重新卡死。Nginx 示例:
nginxadd_header Cross-Origin-Opener-Policy same-origin; add_header Cross-Origin-Embedder-Policy require-corp;vite preview也不读server.headers,要本地预览构建产物得用preview.headers配。验证标准只有一个:线上页面 Console 跑
self.crossOriginIsolated,必须是true;再实际转一个 3 秒小视频,能出图、能下载,才算通过。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 命令的常规活儿。如果你的项目是文档站、对速度不敏感,直接走单线程能省掉前两个坑里的大部分麻烦。