我用 Electron + FFmpeg 做了一个本地视频处理工作站 ClipForge

不上传云端、不依赖在线服务,所有视频处理都在本地完成。这篇文章分享 ClipForge 的技术架构和实现思路。

为什么要做这个项目?

首先我正在负责的这个项目就是一个视频剪辑器,对标剪映的,然后想更完善的学习一下视频剪辑相关的技术;还有就是之前自己玩有用到过视频处理的软件,所以想着自己开发一个。

另外市面上有很多在线视频处理工具,但大部分都有以下问题:

  • 隐私风险:视频必须上传到云端,敏感内容无法保证安全
  • 文件大小限制:大文件上传慢,还有容量限制
  • 付费墙:好用的功能基本都要订阅

作为一个开发者,我需要一个完全本地化、功能齐全、界面现代的视频处理工具。于是 ClipForge 诞生了。

项目概览

ClipForge 是一个跨平台桌面应用,基于 Electron + React + FFmpeg 构建,支持 Windows、macOS、Linux。

核心能力:

类别 功能
转码压缩 MP4/WebM/MKV/MOV/AVI/GIF 格式转换、缩放压缩、去除元数据
画面处理 裁剪、去水印、旋转翻转、调色、降噪、锐化/模糊、截帧
时间速度 变速(0.25x-4x)、倒放、回旋、循环、淡入淡出
音频处理 提取音频、静音、音量调整、响度归一化
多输入合成 视频拼接、并排对比、画中画、水印叠加、混合音频、嵌入字幕
工具 媒体信息查看、自定义 FFmpeg 命令

支持三种工作模式:单项处理操作链 (多个操作组合)、批量处理

技术架构

整体架构

arduino 复制代码
┌─────────────────────────────────────────────┐
│                 Electron App                 │
──────────────────┬──────────────────────────┤
│   Main Process   │    Renderer Process      │
│   (Node.js)      │    (React + Vite)        │
│                  │                          │
│  ┌────────────┐  │  ┌────────────────────┐  │
│  │ FFmpeg     │  │  │ UI Components      │  │
│  │ Runner     │  │  │ - PreviewCanvas    │  │
│  └─────┬──────┘  │  │ - Inspector        │  │
│        │         │  │ - TrimTrack        │  │
│  ┌─────┴──────┐  │  │ - MediaPanel       │  │
│  │ IPC        │◄─┼──┤                    │  │
│  │ Handlers   │  │  ────────┬───────────┘  │
│  └────────────┘  │           │              │
│                  │  ┌────────┴───────────┐  │
│  ┌────────────┐  │  │ Zustand Stores     │  │
│  │ File System│  │  │ - mediaStore       │  │
│  │ Operations │  │  │ - opStore          │  │
│  └────────────┘  │  │ - processStore     │  │
│                  │  │ - stackStore       │  │
│                  │  └────────────────────┘  │
└──────────────────┴──────────────────────────┘

为什么选 Electron?

FFmpeg 是命令行工具,需要一个壳来承载 UI。选 Electron 的原因:

  1. Node.js 子进程child_process.spawn 直接调用 FFmpeg,天然适合
  2. 跨平台:一次开发,三端部署
  3. React 生态:组件化开发,状态管理成熟
  4. 本地文件访问fs 模块直接读写,无需额外权限

FFmpeg 二进制管理

FFmpeg 二进制文件随应用一起分发,不需要用户额外安装。

bash 复制代码
src/main/ffmpeg/
── binary.ts          # 路径解析(开发/打包模式自适应)
├── build-args.ts      # 操作 → FFmpeg 参数映射
├── runner.ts          # 子进程管理 + 进度解析
├── darwin-arm64/ffmpeg
├── darwin-x64/ffmpeg
── win32-arm64/ffmpeg.exe
└── win32-x64/ffmpeg.exe

路径解析逻辑(binary.ts):

typescript 复制代码
export function getFfmpegPath(): string {
  // 打包模式:resources/ffmpeg/<platform>-<arch>/ffmpeg
  const packagedPath = path.join(process.resourcesPath, 'ffmpeg', platDir, `ffmpeg${exe}`);
  if (fs.existsSync(packagedPath)) return packagedPath;

  // 开发模式:src/main/ffmpeg/<platform>-<arch>/ffmpeg
  const devPath = path.join(app.getAppPath(), 'src', 'main', 'ffmpeg', platDir, `ffmpeg${exe}`);
  if (fs.existsSync(devPath)) return devPath;

  // Fallback:构建产物相对路径
  const fallbackPath = path.join(__dirname, '..', 'main', 'ffmpeg', platDir, `ffmpeg${exe}`);
  if (fs.existsSync(fallbackPath)) return fallbackPath;

  throw new Error(`ffmpeg binary not found for ${platDir}`);
}

打包配置(forge.config.ts):

typescript 复制代码
packagerConfig: {
  asar: { unpackDir: 'src/main/ffmpeg' },  // 二进制不进 asar
  extraResource: ['src/main/ffmpeg'],       // 复制到 resources/
  executableName: 'clipforge',              // Linux 可执行文件名
},

操作 → FFmpeg 参数映射

这是项目的核心逻辑。每个操作(Operation)定义了如何转换为 FFmpeg 命令行参数。

以去水印为例,使用 crop + 高斯模糊 + 半透明叠加方案:

typescript 复制代码
case 'delogo': {
  const x = Math.max(0, Math.round(Number(p.x) || 0));
  const y = Math.max(0, Math.round(Number(p.y) || 0));
  const w = Math.max(10, Math.round(Number(p.w) || 10));
  const h = Math.max(10, Math.round(Number(p.h) || 10));
  args.push('-filter_complex',
    `[0:v]split[a][b];[b]crop=${w}:${h}:${x}:${y},gblur=sigma=30,` +
    `format=rgba,colorchannelmixer=aa=0.7[b2];[a][b2]overlay=${x}:${y}[out]`
  );
  args.push('-map', '[out]', '-map', '0:a?');
  args.push(...videoCodec(outExt));
  break;
}

设计要点:

  • crop 提取水印区域
  • gblur 高斯模糊(比 boxblur 更自然)
  • colorchannelmixer=aa=0.7 半透明叠加,让处理区域与画面融合

实时预览系统

预览是体验的关键。用户调整参数后需要立即看到效果,而不是等处理完成。

方案:Canvas 实时绘制

不是真的跑 FFmpeg 预览,而是在 <canvas> 上模拟滤镜效果:

typescript 复制代码
// 根据操作链构建预览操作列表
const previewOps = buildPreviewOps(mode, selectedOpId, params, stack);

// 每帧绘制:从 video 元素读取画面 → 应用操作 → 绘制到 canvas
useEffect(() => {
  const render = () => {
    drawPreview(ctx, video, previewOps, { width: rect.width, height: rect.height });
  };
  render();
  if (playing) {
    const loop = () => { render(); raf = requestAnimationFrame(loop); };
    raf = requestAnimationFrame(loop);
  }
}, [playing, playhead, JSON.stringify(previewOps)]);

这样用户在调整亮度、裁剪区域、旋转角度时,可以实时看到预览效果。

坐标换算:屏幕像素 → 视频像素

去水印功能需要鼠标框选水印区域。用户在屏幕上拖拽的坐标需要换算为视频原始像素坐标。

视频在容器内是 letterbox 等比缩放居中的,所以换算公式:

ini 复制代码
scale = min(containerWidth / videoWidth, containerHeight / videoHeight)
offsetX = (containerWidth - videoWidth * scale) / 2
offsetY = (containerHeight - videoHeight * scale) / 2

videoX = (screenX - offsetX) / scale
videoY = (screenY - offsetY) / scale
typescript 复制代码
function screenToVideo(localX, localY, container, videoW, videoH) {
  const { scale, ox, oy } = getVideoMapping(container, videoW, videoH);
  return {
    x: Math.max(0, Math.min(Math.round((localX - ox) / scale), videoW)),
    y: Math.max(0, Math.min(Math.round((localY - oy) / scale), videoH)),
  };
}

状态管理

使用 Zustand 管理应用状态,每个 store 职责单一:

bash 复制代码
store/
├── mediaStore.ts     # 文件列表、选中文件、时长
├── opStore.ts        # 当前操作、参数值
├── stackStore.ts     # 操作链(Stack 模式)
├── processStore.ts   # 处理进度、状态
├── trimStore.ts      # 裁剪区间
├── modeStore.ts      # 工作模式(单项/操作链/批量)
└── localeStore.ts    # 国际化

Zustand 的优势:

  • 没有 Provider 包裹,组件直接 useMediaStore() 调用
  • 支持 selector,避免不必要的重渲染
  • TypeScript 类型推导友好

IPC 通信

Electron 主进程和渲染进程通过 IPC 通信。关键设计:

scss 复制代码
渲染进程                    预加载脚本                    主进程
─────────                  ──────────                   ────────
api.openFiles()  ──invoke──►  ipcRenderer.invoke  ──►  ipcMain.handle
                                                     ──►  dialog.showOpenDialog
                                                     ◄──  return MediaFile[]

api.revealInFolder() ──send──►  ipcRenderer.send   ──►  ipcMain.on
                                                     ──►  shell.showItemInFolder
  • invoke/handle:需要返回值的操作(文件选择、FFmpeg 就绪检查)
  • send/on:不需要返回值的操作(打开文件夹、窗口控制)
  • 事件推送 :主进程通过 webContents.send 推送进度和日志

打包与分发

使用 Electron Forge 打包,GitHub Actions 自动构建三平台:

yaml 复制代码
jobs:
  build:
    strategy:
      matrix:
        os: [macos-latest, ubuntu-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm run make

产物:

  • macOS:.dmg + .zip
  • Linux:.deb + .rpm
  • Windows:.exe 安装包

踩过的坑

ffmpeg 的 delogo 滤镜要求 x >= 1, y >= 1, y + h < videoHeight。贴边的水印直接报错。

解决 :改用 crop + gblur + overlay 方案,支持任意位置(包括贴边)。

2. React 闭包陷阱

拖拽选区的 onUp 回调中读取 liveRect(useState),拿到的是拖拽前的旧值。

解决 :改用 useRef 存储拖拽中的实时坐标,onUp 中从 ref.current 读取。

3. asar 打包二进制文件

FFmpeg 二进制不能放进 asar 压缩包,否则无法直接执行。

解决asar: { unpackDir: 'src/main/ffmpeg' } 排除,extraResource 复制到 resources/

4. 视频流式加载

<video> 标签需要 HTTP Range 请求支持才能实现拖动进度条跳转。

解决 :自定义 media:// 协议处理器,解析 Range 请求头,返回 206 Partial Content

typescript 复制代码
protocol.handle('media', async (request) => {
  const range = request.headers.get('Range');
  if (range) {
    // 解析 Range 头,创建 ReadStream(start, end)
    return new Response(stream, {
      status: 206,
      headers: {
        'Content-Range': `bytes ${start}-${end}/${total}`,
        'Accept-Ranges': 'bytes',
      },
    });
  }
  // 完整返回
  return new Response(stream, { status: 200 });
});

许可证

MIT + Commons Clause:个人使用免费,商业用途需联系作者授权。

开源地址

GitHub: clipforge

欢迎 Star、Issue 和 PR!

相关推荐
前端Hardy1 小时前
又一个 AI 神器火了!
前端·javascript·后端
锋行天下1 小时前
我试图优化 Vite 的拆包,结果首屏慢了 10 倍
前端·vue.js·架构
PBitW2 小时前
GPT训练我的第二天,我表示不过如此!!!😕😕😕
前端·javascript·面试
用户99045017780092 小时前
学习了AI修图,我把自己闲鱼出租房照片整成airbnb风格了
前端
kyriewen3 小时前
白宫直接给 OpenAI 下了限制令,GPT-5.6 不能随便放出来了
前端·javascript·面试
PedroQue994 小时前
Vite插件v0.2.6:架构优化与自动化升级
前端·vite
threerocks5 小时前
什么?我连 A2A、MCP 都没学会,现在又来了 AG-UI、A2UI.
前端·aigc·ai编程
牛奶6 小时前
如何自己写一个浏览器插件?
前端·chrome·浏览器
亿元程序员6 小时前
为什么Cocos都4.0了还有人用2.x?
前端