为什么需要浏览器端压缩
用户上传视频时,直接上传原始文件有几个问题:上传慢、存储贵、播放卡。在浏览器端先压缩再上传,可以减少 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 上提升加载速度。
总结
浏览器端视频压缩是可行的,但有几个要点:
- 用 0.12.x 版本,API 和 0.11.x 不同
- 0.12.x 多线程版本有 bug,当前只用单线程
- blob URL 延迟释放,避免 ERR_FILE_NOT_FOUND
- 事件监听器压缩完必须移除
- ultrafast + zerolatency + copy 三板斧提速
- 大文件看电脑内存,超过 1GB 用后端转码