前言
短视频时代,视频剪辑已经成为一项基础技能。传统桌面剪辑软件(如 Premiere、Final Cut Pro)功能强大但门槛较高,而在线剪辑工具(如剪映网页版)则提供了更轻量的替代方案。本文将介绍如何使用 Web 技术栈构建一个轻量级在线视频剪辑器,涵盖多轨道编辑、实时预览、视频导出等核心功能的实现思路。
一、技术选型
前端
| 技术 | 选型 | 理由 |
|---|---|---|
| 框架 | React 19 + TypeScript | 组件化开发,类型安全 |
| 状态管理 | Redux Toolkit | 复杂时间线状态需要集中管理 |
| UI 组件库 | Ant Design 6 | 丰富的表单、弹窗组件 |
| 构建工具 | Vite | 极速的 HMR 开发体验 |
后端
| 技术 | 选型 | 理由 |
|---|---|---|
| 运行时 | Node.js + Express | 前后端统一语言 |
| 视频处理 | fluent-ffmpeg | Node.js 的 ffmpeg 封装,API 友好 |
| 数据库 | MySQL 8.0 | 存储素材和项目数据 |
| 文件上传 | multer | 成熟的 multipart 处理方案 |
核心依赖
视频剪辑的核心是 ffmpeg——一个几乎无所不能的多媒体处理工具。fluent-ffmpeg 是它的 Node.js 封装,提供了链式 API:
ffmpeg(inputPath)
.outputOptions(['-c:v libx264', '-crf 23'])
.output(outputPath)
.on('end', () => console.log('完成'))
.run();
二、系统架构
┌─────────────────────────────────────────────────┐
│ 前端 (React) │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ Material │ │ Preview │ │ Timeline │ │
│ │ Lib │ │ │ │ (video/audio/sub)│ │
│ └──────────┘ └──────────┘ └──────────────────┘ │
│ │ Redux │
│ ▼ │
│ ┌────────────┐ │
│ │ editorSlice│ │
│ └────────────┘ │
└────────────────────────┬────────────────────────┘
│ HTTP API
┌────────────────────────▼────────────────────────┐
│ 后端 (Express) │
│ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ Material │ │ Export Engine │ │
│ │ Routes │ │ (fluent-ffmpeg + concat) │ │
│ └──────────────┘ └──────────────────────────┘ │
│ │ │
│ ┌──────┴──────┐ │
│ │ MySQL │ │
│ └─────────────┘ │
└─────────────────────────────────────────────────┘
前端负责 UI 交互和状态管理,后端负责文件存储和视频处理。两者通过 RESTful API 通信。
三、核心模块实现
3.1 时间线数据模型
时间线是剪辑器的核心。我们用 Redux 管理整个编辑状态:
// editorSlice.ts
interface TimelineClip {
id: string;
materialId: string; // 关联素材 ID
trackIndex: number; // 所在轨道索引
startTime: number; // 在时间线上的起始时间(秒)
endTime: number; // 结束时间
duration: number; // 持续时长
trimStart: number; // 素材裁剪起点
trimEnd: number; // 素材裁剪终点
muted?: boolean; // 是否静音(视频轨道)
text?: string; // 文本内容(字幕/文本轨道)
textStyle?: TextStyle; // 文本样式
}
interface Track {
id: string;
type: 'video' | 'audio' | 'subtitle' | 'text';
name: string;
clips: TimelineClip[];
}
// 初始状态包含 4 条轨道
const initialState = {
tracks: [
{ id: 'video-1', type: 'video', name: '视频轨道', clips: [] },
{ id: 'text-1', type: 'text', name: '文本轨道', clips: [] },
{ id: 'audio-1', type: 'audio', name: '音频轨道', clips: [] },
{ id: 'subtitle-1', type: 'subtitle', name: '字幕轨道', clips: [] },
],
currentTime: 0,
duration: 0,
isPlaying: false,
};
这种设计的优势:
- 数据驱动 UI:时间线、预览、导出都从同一份状态派生
- 撤销/重做:Redux 天然支持时间旅行调试
- 序列化:状态可以直接 JSON 序列化保存到数据库
3.2 时间线渲染与交互
时间线的核心是将时间映射为像素位置:
const PIXELS_PER_SECOND = 50;
const zoom = 1; // 缩放比例
// 时间 → 像素
const left = clip.startTime * PIXELS_PER_SECOND * zoom;
const width = clip.duration * PIXELS_PER_SECOND * zoom;
// 像素 → 时间(点击定位)
const time = clickX / (PIXELS_PER_SECOND * zoom);
拖拽移动和边缘裁剪通过监听 mousemove 实现:
const handleMouseMove = (e: MouseEvent) => {
if (isDragging && dragData) {
const dx = e.clientX - dragData.startX;
const dt = dx / (PIXELS_PER_SECOND * zoom);
const newStartTime = Math.max(0, dragData.startTime + dt);
dispatch(updateClip({
trackId: dragData.trackId,
clipId: dragData.clipId,
updates: { startTime: newStartTime, endTime: newStartTime + clip.duration }
}));
}
};
3.3 实时预览引擎
预览是最具挑战性的部分之一。浏览器原生的 <video> 元素只能播放单个文件,但我们需要播放时间线上拼接的多个片段。
方案:时钟驱动 + 视频同步
// 用系统时钟驱动时间线推进
const playbackStartRef = useRef<{ time: number; wallClock: number }>();
const updateTime = () => {
if (!isPlaying || !playbackStartRef.current) return;
const elapsed = (Date.now() - playbackStartRef.current.wallClock) / 1000;
const newTime = playbackStartRef.current.time + elapsed;
if (newTime <= duration) {
dispatch(setCurrentTime(newTime));
} else {
dispatch(setIsPlaying(false));
}
requestAnimationFrame(updateTime);
};
当 currentTime 变化时,查找当前活跃的片段并切换视频源:
useEffect(() => {
const videoTrack = tracks.find(t => t.type === 'video');
const activeClip = videoTrack?.clips.find(
clip => currentTime >= clip.startTime && currentTime < clip.endTime
);
if (activeClip) {
const material = materials.find(m => m.id === activeClip.materialId);
setActiveMedia({ url: material.url, type: material.type });
}
}, [currentTime, tracks, materials]);
关键细节:用 key 属性强制 React 在切换片段时重建 <video> 元素,避免残留上一段视频的画面:
<video key={activeMedia.url} src={activeMedia.url} />
3.4 视频导出流水线
导出是后端的核心功能。整个流程分为 3 个阶段:
阶段 1:视频轨道处理
每个片段单独处理,然后用 ffmpeg concat 拼接:
// 处理单个片段
async function processClip(clip, material, outputPath, resolution, crf, fps) {
const isImage = material.type === 'image';
if (isImage) {
// 图片转视频:循环指定时长
ffmpeg()
.input(sourcePath)
.input('anullsrc=channel_layout=stereo:sample_rate=44100') // 静音音频流
.loop(clipDuration)
.outputOptions(['-c:v libx264', '-pix_fmt yuv420p', '-t', clipDuration]);
} else {
// 视频裁剪 + 缩放
ffmpeg(sourcePath)
.outputOptions([
`-vf scale=${width}:${height}:force_original_aspect_ratio=decrease`,
'-c:v libx264', '-crf', crf, '-r', fps
]);
}
}
多片段拼接使用 ffmpeg 的 concat demuxer:
// 生成 concat 列表文件
const concatList = segmentPaths.map(p => `file '${p}'`).join('\n');
fs.writeFileSync(concatListPath, concatList);
// 拼接
ffmpeg()
.input(concatListPath)
.inputOptions(['-f', 'concat', '-safe', '0'])
.outputOptions(['-c', 'copy'])
.output(finalPath);
阶段 2:音频混合
将音频轨道的片段按时间位置混合到视频中:
// 提取音频片段
ffmpeg(sourcePath)
.outputOptions(['-c:a aac', '-vn'])
.setStartTime(clip.trimStart)
.duration(clip.duration);
// 用 adelay 滤镜定位,amix 混合
const filter = `[1:a]adelay=${delayMs}|${delayMs}[a1];[a1]amix=inputs=1:duration=first[aout]`;
ffmpeg()
.input(videoPath)
.input(audioPath)
.complexFilter(filter)
.outputOptions(['-map', '0:v', '-map', '[aout]', '-c:v copy']);
阶段 3:字幕烧录
使用 ffmpeg 的 drawtext 滤镜将字幕烧录到视频:
const filter = `drawtext=text='${text}':fontsize=${size}:fontcolor=${color}` +
`:box=1:boxcolor=${bgColor}:boxborderw=8` +
`:x=(w-tw-${boxBorderw * 2})/2:y=${y}` +
`:enable='between(t\\,${start}\\,${end})'`;
3.5 文本编辑与定位
文本轨道支持在预览中直接拖拽定位和双击编辑:
// 拖拽移动
const handleMouseMove = (e: MouseEvent) => {
const dx = ((e.clientX - startX) / containerWidth) * 100;
const dy = ((e.clientY - startY) / containerHeight) * 100;
dispatch(updateClip({
trackId, clipId,
updates: { textStyle: { ...style, x: origX + dx, y: origY + dy } }
}));
};
中文输入需要处理 IME 组合事件,否则拼音会被当作最终值:
<textarea
defaultValue={text}
onCompositionStart={() => { isComposing = true; }}
onCompositionEnd={(e) => {
isComposing = false;
updateText(e.target.value);
}}
onChange={(e) => {
if (!isComposing) updateText(e.target.value);
}}
/>
四、踩过的坑
4.1 nodemon 误重启
在 exports/ 目录下创建临时片段文件时,nodemon 检测到文件变化自动重启服务,导致导出中断。
解决方案:将临时文件放到系统临时目录 os.tmpdir(),并配置 nodemon 只监听 src/ 目录。
4.2 ffmpeg concat 音频丢失
图片片段和静音视频片段没有音频流,与有音频的片段拼接时,整个视频的音频丢失。
解决方案:为所有片段统一生成音频流,无音频的片段用 anullsrc 生成静音轨道。
4.3 播放到片段边界卡住
最初用 <video> 元素的 currentTime 驱动时间线,视频播放结束时时间线停止推进。
解决方案:改用系统时钟(Date.now())驱动时间线,视频元素只负责渲染,两者通过 currentTime 单向同步。
五、性能优化
- 片段懒加载:只加载当前播放位置附近的视频资源
- 预览降质:预览时使用较低分辨率,导出时使用原始质量
- Web Worker:将耗时的状态计算放到 Worker 线程(待实现)
- 虚拟列表:素材列表使用虚拟滚动,支持大量素材
六、未来规划
- 撤销/重做(基于 Redux 中间件)
- 转场效果(交叉溶解、淡入淡出)
- 滤镜效果(亮度、对比度、饱和度)
- 关键帧动画
- 项目保存与加载
- WebSocket 实时导出进度
- WebAssembly 版 ffmpeg(客户端导出)
总结
构建一个 Web 视频剪辑器涉及前端状态管理、多媒体处理、实时渲染等多个技术领域。核心挑战在于:
- 时间线模型设计:需要足够灵活以支持各种编辑操作
- 预览性能:浏览器的
<video>元素能力有限,需要巧妙的架构设计 - 导出流水线:ffmpeg 功能强大但 API 复杂,需要仔细处理各种边界情况
这个项目还有很多可以改进的地方,但已经验证了 Web 技术栈构建视频剪辑器的可行性。希望本文能为有类似需求的开发者提供一些参考。
技术栈总结:React 19 + TypeScript + Redux Toolkit + Ant Design + Express + fluent-ffmpeg + MySQL