秒切——纯前端一键视频切割工具:浏览器也是 FFmpeg 的舞台
在当今这个短视频爆炸的时代,视频处理已经成为了许多开发者和内容创作者的必备技能。但你有没有想过,只用前端技术,就能在浏览器里实现视频切割呢?作者开发的秒切 正是这样一款强大的工具,它完全基于前端技术,在浏览器中就能轻松完成按秒(时间段)切割视频。
用户只需上传视频文件,选择快速模式或精确模式,然后设置每段视频的时长,点击“开始分割”按钮即可。快速模式适合大部分场景,能够快速完成分割,而精确模式则可以实现帧级精度,满足专业需求。用户可以实时查看FFmpeg处理日志,了解切割进度。处理完成后,用户可以在弹窗中直接播放所有分割片段。
秒切的便捷性还体现在它 Web 应用的特性:免安装使用上。用户无需安装任何软件,也不需要花费时间上传文件到服务器,直接在浏览器里就能完成分割。此外,它还支持移动端,随时随地都可以使用。
今天,就让我们一起在 秒切 这个案例的源代码中,探索如何利用 FFmpeg WASM 在浏览器中实现“按秒切割视频”的神奇功能。
技术栈与核心库
在开始之前,让我们先来了解一下本项目所使用的技术栈和核心库。
我们的技术栈包括 Next.js、React、TypeScript 和 Tailwind CSS,另外还使用了基于 tailwind css 的 UI 组件库 glint-ui。
而此功能的核心库则是 @ffmpeg/ffmpeg
和 @ffmpeg/util
,它们将帮助我们在浏览器中运行 FFmpeg,实现视频处理的功能。
目录结构
需要说明的是,秒切是 Dors.——花野猫的数字花园 的一个子功能,其代码实现在 dors 这个 github 仓库中,所以目录结构也遵循 dors 的目录结构设计。整个功能其实是一个 Next.js app router 的路由,代码存放在 @/app/(projects)/video-splitter
目录下,主要文件包括:
page.tsx
:页面文件,包含右侧的“使用指南”区域。VideoSplitter.tsx
:主功能组件,负责 FFmpeg 的初始化、视频切割流程以及弹窗预览。
1. 初始化 FFmpeg(WASM 加载)
FFmpeg 在浏览器中运行依赖于 WASM 核心文件。在项目中,我们通过 toBlobURL
从 CDN 拉取并加载这些文件。以下是初始化 FFmpeg 的代码:
// VideoSplitter.tsx(节选)
const ffmpegRef = useRef(new FFmpeg());
const loadFFmpeg = async () => {
const baseURL = "https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd ";
const ffmpeg = ffmpegRef.current;
try {
setFfmpegLoading(true);
setFfmpegProgress(0);
// 简单的“加载进度”模拟(UI 友好)
const progressInterval = setInterval(() => {
setFfmpegProgress((prev) => (prev >= 90 ? (clearInterval(progressInterval), 90) : prev + 10));
}, 100);
await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"),
});
clearInterval(progressInterval);
setFfmpegProgress(100);
setTimeout(() => {
toast.success(t.ffmpegLoaded);
setFfmpegLoaded(true);
setFfmpegLoading(false);
}, 500);
} catch (error) {
console.error(error);
toast.error(t.ffmpegLoadFail);
setFfmpegLoading(false);
}
};
useEffect(() => {
loadFFmpeg();
}, []);
要点
- 首次进入页面即加载 FFmpeg WASM。
- UI 显示“加载中 + 进度条”,避免白屏。
- 加载完成后设置
ffmpegLoaded
,作为“开始分割”按钮的可用条件之一。
2. 选择与预览视频(点击/拖拽)
文件选择使用自定义的 BaseInputFileHeadless
,同时支持拖拽区域。以下是代码示例:
// VideoSplitter.tsx(节选)
<BaseInputFileHeadless
ref={fileInputRef}
accept="video/*"
// @ts-ignore
onChange={handleFileSelect}
filterFileDropped={(file) => file.type.startsWith('video/')}
renderContent={({ open, drop, files }) => (
<div
className={cn("relative w-full min-h-72 ...", { /* 省略样式 */ })}
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
onDragLeave={(e) => { e.preventDefault(); setIsDragOver(false); }}
onDrop={handleDragDrop}
onClick={open}
>
<video ref={videoRef} controls className={cn("w-full h-full object-contain", { hidden: !selectedFile })}>
<source src="" type="video/mp4" />
</video>
{/* 省略:未选择文件时的引导 UI、示例视频按钮与预加载 */}
</div>
)}
/>
选择文件后,生成一个临时 URL 并载入 <video>
,同时读取原视频时长:
const loadVideoFile = async (file: File) => {
const videoUrl = URL.createObjectURL(file);
if (videoRef.current) {
videoRef.current.src = videoUrl;
videoRef.current.load();
videoRef.current.onloadedmetadata = () => {
if (videoRef.current) setVideoDuration(videoRef.current.duration);
};
}
setSelectedFile(file);
setSegments([]);
setCurrentSegment(null);
};
3. 分割模式与 FFmpeg 命令
应用提供两种模式,使用单选切换:
- 快速模式:
copy
直拷贝,不重编码,速度快但切点不一定“帧精确”。 - 精确模式:H.264 + AAC 重新编码,切点更准但耗时更长。
对应的 FFmpeg 命令如下:
// VideoSplitter.tsx(节选)
if (splitMode === "fast") {
await ffmpeg.exec([
"-i", "input.mp4",
"-c", "copy",
"-map", "0",
"-segment_time", duration.toString(),
"-reset_timestamps", "1",
"-f", "segment",
`${selectedFile.name.split(".")[0]}%03d.mp4`,
]);
} else {
await ffmpeg.exec([
"-i", "input.mp4",
"-c:v", "libx264",
"-c:a", "aac",
"-preset", "fast",
"-segment_time", duration.toString(),
"-segment_time_delta", "0.1",
"-f", "segment",
"-reset_timestamps", "1",
"-map", "0:v:0",
"-map", "0:a:0",
`${selectedFile.name.split(".\n)[0]}%03d.mp4`,
]);
}
在执行命令之前,需要将原始文件写入 FFmpeg 的虚拟文件系统:
await ffmpeg.writeFile("input.mp4", new Uint8Array(await selectedFile.arrayBuffer()));
4. 进度条与日志解析
FFmpeg 处理过程中会输出大量日志。我们通过 ffmpeg.on('log', handler)
订阅日志,粗略估算处理进度:
// VideoSplitter.tsx(节选)
let currentProgress = 20;
let frameCount = 0;
let totalFrames = videoDuration > 0 ? Math.ceil(videoDuration * 30) : 0; // 简化估算为 30fps
const logHandler = (log: any) => {
const logText = log.message || log;
if (typeof logText === 'string') setProcessingLogs((prev) => [...prev, logText]);
const frameMatch = typeof logText === 'string' && logText.match(/frame=\s*(\d+)/);
if (frameMatch) {
frameCount = parseInt(frameMatch[1]);
if (totalFrames > 0) {
const frameProgress = Math.min((frameCount / totalFrames) * 50, 50); // 将 20%-70% 区间用于编码进度
currentProgress = 20 + frameProgress;
setProgress(Math.round(currentProgress));
}
}
if (typeof logText === 'string' && logText.includes('video:') && logText.includes('audio:')) {
setProgress(75);
}
};
ffmpeg.on('log', logHandler);
// ... 执行命令
ffmpeg.off('log', logHandler);
进度阶段
- 0%–20%:文件写入虚拟文件系统。
- 20%–70%:依据日志中的
frame=
粗估。 - 70%–100%:读取结果、拼装片段。
注意:这是“估算”进度,不同格式/设备可能会有偏差。
5. 读取输出并计算片段真实时长
执行完成后,枚举虚拟目录下的输出文件,读取为 Blob
,并用临时 <video>
获取实际时长:
const files = await ffmpeg.listDir(".");
const outputFiles = files.filter((f) => f.name !== "input.mp4" && f.name.endsWith(".mp4"));
const newSegments: Segment[] = [];
for (const outputFile of outputFiles) {
const data = await ffmpeg.readFile(outputFile.name);
const blob = new Blob([data], { type: "video/mp4" });
const url = URL.createObjectURL(blob);
const tempVideo = document.createElement('video');
tempVideo.src = url;
tempVideo.preload = 'metadata';
await new Promise<void>((resolve) => {
tempVideo.onloadedmetadata = () => resolve();
tempVideo.load();
});
newSegments.push({ name: outputFile.name, url, blob, duration: tempVideo.duration || duration });
}
setSegments(newSegments);
6. 预览与下载(结果弹窗)
处理完成后,将会弹出模态框,列出每个片段的可播放 <video>
与下载按钮:
// VideoSplitter.tsx(节选,Modal 内)
{segments.map((segment, index) => (
<div key={segment.name} className="border rounded-lg overflow-hidden">
<div className="relative aspect-video">
<video src={segment.url} className="w-full h-full object-cover" preload="metadata" controls muted />
<div className="absolute top-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded">#{index + 1}</div>
</div>
<div className="p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium text-sm truncate">{segment.name}</span>
</div>
<div className="text-xs">时长:{Math.round(segment.duration)}s</div>
<BaseButton variant="outline" size="sm" onClick={() => downloadSegment(segment)} className="w-full">
下载
</BaseButton>
</div>
</div>
))}
批量下载只是顺序触发多次 a.click()
,稍作延迟:
const downloadAllSegments = () => {
segments.forEach((segment, index) => setTimeout(() => downloadSegment(segment), index * 100));
};
7. 细节处理与产品体验
为了让用户有更好的使用体验,秒切的实现还在细节上做了很多处理:
- 删除/重置:支持“删除视频”和“返回原视频”。
- 示例视频:提供“加载示例视频”,供用户测试使用。
- 估算段数:
Math.ceil(videoDuration / duration)
。 - 处理日志:在进度区域下滚动展示 FFmpeg 输出的关键行,方便排查问题。
9. 性能与限制
虽然我们的工具非常强大,但仍然有一些性能和限制需要注意:
- WASM 首次加载:几十到上百 KB 的核心脚本 + wasm 文件,首次进入页面需要等待。
- 内存与文件大小:完全取决于浏览器与设备,移动端内存更紧张,建议优先测试较小文件。
- 进度估算:基于日志与帧数的“近似值”,不是硬性百分比。
- 切割精度:快速模式不做重编码,切点可能落在关键帧附近;精确模式更准但耗时显著增加。
- 兼容性:现代浏览器(支持 WebAssembly)。
10. 常见问题(FAQ)
- Q:为什么点击“开始分割”是灰的?
- A:需要满足三个条件:已选择视频、FFmpeg 加载完成、当前未在处理中。
- Q:分割出来的片段时长不完全等于设置值?
- A:快速模式下常见,因为不重编码;需要更精确请使用“精确模式”。
- Q:能否导出为其他格式?
- A:可以更换输出扩展名和编码参数,但请注意浏览器的可播放性和编码耗时。
结语
以上就是本应用的完整实现过程与关键代码。我们没有引入后端,没有“云端转码”,所有处理都在浏览器本地完成。你可以直接在此基础上进行二次开发,比如增加格式选择、增加时间轴 UI,或者把分割改为“按时间点列表”。
建议直接打开源码 dors/app/(projects)/video-splitter at main · huayemao/dors 进行对照阅读与调试。希望这篇文章能为你带来启发!