秒切——纯前端一键视频切割工具:浏览器也是 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 进行对照阅读与调试。希望这篇文章能为你带来启发!

posted @ 2025-09-06 17:57  花野猫  阅读(7)  评论(0)    收藏  举报