视频剪辑基础:实现视频裁剪、拼接、变速与倒放

各位同学,欢迎来到第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.widthcanvas.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) 只能做水平翻转,不能做时间倒流。记住:倒放是时间维度的反转,不是空间维度的翻转

知识体系总览

下面这张图,是我梳理的本章核心逻辑。你想想看,所有操作其实都围绕一个中心——把视频当帧序列处理

视频帧序列 时间裁剪 currentTime 跳转 视频拼接 Canvas 逐帧绘制 变速播放 帧采样间隔控制 视频倒放 帧缓存反向绘制 核心思想:所有操作都基于帧序列的重新编排 工具链:HTMLVideoElement → Canvas → MediaRecorder 🔗 🔄

性能优化建议

操作类型 内存消耗 处理时长 优化技巧
时间裁剪 与时长成正比 captureStream 代替逐帧绘制
视频拼接 与片段数成正比 复用 Canvas 上下文
变速播放 与原始时长成正比 快放时跳过帧,慢放时插帧
视频倒放 需要先缓存所有帧 限制最大时长,或用 Web Worker 处理

总结一下:这四个功能的核心都是「帧操作」。裁剪是跳帧,拼接是接帧,变速是抽帧或插帧,倒放是逆序帧。你只要掌握了 Canvas + MediaRecorder 这套组合拳,就能玩出各种花样。

我个人习惯把这类工具封装成一个 VideoEditor 类,把帧缓存、Canvas 管理、录制控制都包在里面。这样在项目中调用起来特别顺手。嗯,代码我就不贴了,留给你们当课后练习。

最后提醒一句:所有操作都会重新编码视频,画质会有一定损失。如果对画质要求高,建议用 video/webm;codecs=vp9 编码,码率设高一点。

公众号:蓝海资料掘金营,微信deep3321