视频剪辑基础:实现视频裁剪、拼接、变速与倒放
各位同学,欢迎来到第20章。说实话,视频剪辑这块功能,在WebRTC录制场景里经常被忽略。很多人觉得录完就完事了,但实际项目中,用户往往需要「剪掉开头那段空白」、「把两段会议录像拼起来」、「慢放某个关键操作」——这些需求,我几乎在每个企业级项目里都遇到过。
今天我们就来啃下这四个硬骨头:时间裁剪、视频拼接、变速播放、倒放效果。我会把我在实际开发中踩过的坑、总结的技巧,都揉进代码里。
核心思路:所有操作都基于 MediaRecorder 录制的 Blob 数据,通过 HTMLVideoElement + Canvas 进行二次加工。说白了,就是把视频当「帧序列」来处理。
20.1 视频裁剪(时间范围)
裁剪是最基础的操作。用户录了10分钟,只想要中间2分钟。怎么做?
我的做法是:用 HTMLVideoElement 加载视频,然后通过 currentTime 跳转到起始点,再用 MediaRecorder 重新录制指定时长。嗯,这里要注意——不能直接操作原始 Blob 的字节,因为视频编码格式太复杂了。
async function trimVideo(videoBlob, startTime, endTime) {
// 1. 创建视频元素
const video = document.createElement('video');
video.src = URL.createObjectURL(videoBlob);
await video.play();
// 2. 跳转到起始时间
video.currentTime = startTime;
// 3. 准备录制
const stream = video.captureStream();
const recorder = new MediaRecorder(stream, {
mimeType: 'video/webm;codecs=vp9'
});
const chunks = [];
recorder.ondataavailable = (e) => chunks.push(e.data);
recorder.start();
// 4. 在结束时间停止录制
const duration = endTime - startTime;
setTimeout(() => {
recorder.stop();
video.pause();
}, duration * 1000);
// 5. 返回新 Blob
return new Promise((resolve) => {
recorder.onstop = () => {
const trimmedBlob = new Blob(chunks, { type: 'video/webm' });
resolve(trimmedBlob);
};
});
}
个人经验:我在项目中遇到过 captureStream() 在某些浏览器上不支持的情况。建议加个降级方案——用 Canvas 逐帧绘制。虽然性能差一点,但兼容性更好。
20.2 视频拼接
拼接比裁剪麻烦一点。你需要把多个视频片段「接」在一起。我最早的做法是直接拼接 Blob,结果发现音画不同步——因为每个片段的编码上下文是独立的。
正确的姿势:用 Canvas 作为中转站。把每个视频片段依次绘制到 Canvas 上,同时用 MediaRecorder 录制整个过程。
async function concatVideos(videoBlobs) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const stream = canvas.captureStream(30); // 30fps
const recorder = new MediaRecorder(stream, {
mimeType: 'video/webm;codecs=vp9'
});
const chunks = [];
recorder.ondataavailable = (e) => chunks.push(e.data);
recorder.start();
for (const blob of videoBlobs) {
const video = document.createElement('video');
video.src = URL.createObjectURL(blob);
await video.play();
// 逐帧绘制到 Canvas
const drawFrame = () => {
if (video.ended) {
video.pause();
return;
}
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0);
requestAnimationFrame(drawFrame);
};
drawFrame();
// 等待视频播放完毕
await new Promise((resolve) => {
video.onended = resolve;
});
}
recorder.stop();
return new Blob(chunks, { type: 'video/webm' });
}
避坑指南:我曾经在拼接时忘记重置 Canvas 尺寸,导致第二个视频被拉伸变形。每次绘制前一定要重新设置 canvas.width 和 canvas.height。
20.3 视频变速播放
变速播放,说白了就是控制 playbackRate。但要注意——直接改 video 的 playbackRate 只影响播放,不影响录制结果。如果你想生成一个「慢放版」的视频文件,还是得用 Canvas 逐帧处理。
我的方案:控制帧的采样间隔。比如 2 倍速,就每隔一帧采样一次;0.5 倍速,就把每帧重复绘制两次。
async function speedChange(videoBlob, rate) {
const video = document.createElement('video');
video.src = URL.createObjectURL(videoBlob);
await video.play();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const stream = canvas.captureStream(30);
const recorder = new MediaRecorder(stream, {
mimeType: 'video/webm;codecs=vp9'
});
const chunks = [];
recorder.ondataavailable = (e) => chunks.push(e.data);
recorder.start();
let frameCount = 0;
const processFrame = () => {
if (video.ended) {
recorder.stop();
return;
}
// 根据速率决定是否绘制当前帧
if (rate >= 1) {
// 快放:每隔 Math.round(rate) 帧采样一次
if (frameCount % Math.round(rate) === 0) {
ctx.drawImage(video, 0, 0);
}
} else {
// 慢放:每帧重复绘制 Math.round(1/rate) 次
for (let i = 0; i < Math.round(1 / rate); i++) {
ctx.drawImage(video, 0, 0);
// 这里需要手动推进时间
}
}
frameCount++;
requestAnimationFrame(processFrame);
};
// 慢放时手动控制时间推进
if (rate < 1) {
const interval = setInterval(() => {
video.currentTime += 1 / 30; // 每帧推进 1/30 秒
ctx.drawImage(video, 0, 0);
}, 1000 / 30 * rate);
video.onended = () => {
clearInterval(interval);
recorder.stop();
};
} else {
requestAnimationFrame(processFrame);
}
return new Blob(chunks, { type: 'video/webm' });
}
我建议:慢放时用 setInterval 手动控制时间,比依赖 requestAnimationFrame 更稳定。因为 RAF 的触发频率和显示器刷新率绑定,容易导致时间偏差。
20.4 视频倒放
倒放效果,说白了就是把视频「反过来播」。这个在 Web 端实现起来有点 trick——你不能直接让 video 元素倒着播,因为 playbackRate 不支持负值。
我的做法:先把所有帧缓存到数组里,然后反向绘制。嗯,这里要注意内存——如果视频很长,帧缓存会吃掉大量内存。我一般限制最大处理时长 30 秒。
async function reverseVideo(videoBlob) {
const video = document.createElement('video');
video.src = URL.createObjectURL(videoBlob);
await video.play();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// 1. 缓存所有帧
const frames = [];
const captureFrames = () => {
if (video.ended) return;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
frames.push(imageData);
requestAnimationFrame(captureFrames);
};
captureFrames();
// 等待视频播完
await new Promise((resolve) => { video.onended = resolve; });
// 2. 反向绘制
const stream = canvas.captureStream(30);
const recorder = new MediaRecorder(stream, {
mimeType: 'video/webm;codecs=vp9'
});
const chunks = [];
recorder.ondataavailable = (e) => chunks.push(e.data);
recorder.start();
for (let i = frames.length - 1; i >= 0; i--) {
ctx.putImageData(frames[i], 0, 0);
await new Promise((r) => requestAnimationFrame(r));
}
recorder.stop();
return new Blob(chunks, { type: 'video/webm' });
}
避坑指南:我曾经在倒放时直接用 drawImage 反向绘制,结果发现 Canvas 的 scale(-1, 1) 只能做水平翻转,不能做时间倒流。记住:倒放是时间维度的反转,不是空间维度的翻转。
知识体系总览
下面这张图,是我梳理的本章核心逻辑。你想想看,所有操作其实都围绕一个中心——把视频当帧序列处理。
性能优化建议
| 操作类型 | 内存消耗 | 处理时长 | 优化技巧 |
|---|---|---|---|
| 时间裁剪 | 低 | 与时长成正比 | 用 captureStream 代替逐帧绘制 |
| 视频拼接 | 中 | 与片段数成正比 | 复用 Canvas 上下文 |
| 变速播放 | 低 | 与原始时长成正比 | 快放时跳过帧,慢放时插帧 |
| 视频倒放 | 高 | 需要先缓存所有帧 | 限制最大时长,或用 Web Worker 处理 |
总结一下:这四个功能的核心都是「帧操作」。裁剪是跳帧,拼接是接帧,变速是抽帧或插帧,倒放是逆序帧。你只要掌握了 Canvas + MediaRecorder 这套组合拳,就能玩出各种花样。
我个人习惯把这类工具封装成一个 VideoEditor 类,把帧缓存、Canvas 管理、录制控制都包在里面。这样在项目中调用起来特别顺手。嗯,代码我就不贴了,留给你们当课后练习。
最后提醒一句:所有操作都会重新编码视频,画质会有一定损失。如果对画质要求高,建议用 video/webm;codecs=vp9 编码,码率设高一点。
公众号:蓝海资料掘金营,微信deep3321