不上传云端、不依赖在线服务,所有视频处理都在本地完成。这篇文章分享 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 的原因:
- Node.js 子进程 :
child_process.spawn直接调用 FFmpeg,天然适合 - 跨平台:一次开发,三端部署
- React 生态:组件化开发,状态管理成熟
- 本地文件访问 :
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安装包
踩过的坑
1. FFmpeg delogo 滤镜的边界限制
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:个人使用免费,商业用途需联系作者授权。
开源地址
欢迎 Star、Issue 和 PR!