在当今这个短视频爆炸的时代,视频处理已经成为了许多开发者和内容创作者的必备技能。但你有没有想过,只用前端技术,就能在浏览器里实现视频切割呢?作者开发的秒切 正是这样一款强大的工具,它完全基于前端技术,在浏览器中就能轻松完成按秒(时间段)切割视频。
用户只需上传视频文件,选择快速模式或精确模式,然后设置每段视频的时长,点击“开始分割”按钮即可。快速模式适合大部分场景,能够快速完成分割,而精确模式则可以实现帧级精度,满足专业需求。用户可以实时查看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 的代码:
ts
// 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
,同时支持拖拽区域。以下是代码示例:
tsx
// VideoSplitter.tsx(节选)<BaseInputFileHeadlessref={fileInputRef}accept="video/*"// @ts-ignoreonChange={handleFileSelect}filterFileDropped={(file) => file.type.startsWith('video/')}renderContent={({ open, drop, files }) => (<divclassName={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>
,同时读取原视频时长:
ts
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 命令如下:
ts
// 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 的虚拟文件系统:
ts
await ffmpeg.writeFile("input.mp4", new Uint8Array(await selectedFile.arrayBuffer()));
4. 进度条与日志解析
FFmpeg 处理过程中会输出大量日志。我们通过 ffmpeg.on('log', handler)
订阅日志,粗略估算处理进度:
ts
// VideoSplitter.tsx(节选)let currentProgress = 20;let frameCount = 0;let totalFrames = videoDuration > 0 ? Math.ceil(videoDuration * 30) : 0; // 简化估算为 30fpsconst 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>
获取实际时长:
ts
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>
与下载按钮:
tsx
// 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()
,稍作延迟:
ts
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 进行对照阅读与调试。希望这篇文章能为你带来启发!