秒切——纯前端一键视频切割工具:浏览器也是 FFmpeg 的舞台
工具FFmpegWasmglintUI秒切

秒切——纯前端一键视频切割工具:浏览器也是 FFmpeg 的舞台

“秒切”是一款纯前端的视频切割工具。它基于FFmpeg WASM,在浏览器中实现视频按秒切割,无需后端支持。用户上传视频后,可选择快速或精确模式进行切割。工具界面简洁,操作便捷,还提供实时日志和进度显示。其技术栈包括Next.js、React、TypeScript等,核心库为@ffmpeg/ffmpeg

更新于 2025-08-29
6532

在当今这个短视频爆炸的时代,视频处理已经成为了许多开发者和内容创作者的必备技能。但你有没有想过,只用前端技术,就能在浏览器里实现视频切割呢?作者开发的秒切 正是这样一款强大的工具,它完全基于前端技术,在浏览器中就能轻松完成按秒(时间段)切割视频。

秒切 截图
秒切 截图

用户只需上传视频文件,选择快速模式或精确模式,然后设置每段视频的时长,点击“开始分割”按钮即可。快速模式适合大部分场景,能够快速完成分割,而精确模式则可以实现帧级精度,满足专业需求。用户可以实时查看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(节选)
<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>,同时读取原视频时长:

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; // 简化估算为 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> 获取实际时长:

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 进行对照阅读与调试。希望这篇文章能为你带来启发!