返回

React Konva MediaRecorder 字幕不同步? 5个解决方案

javascript

React + Konva + MediaRecorder: 录制字幕不同步?试试这些办法!

最近做个小项目,用react-konva画图,再用MediaRecorder录下来做成视频。有个需求是加字幕——一个字符串数组,每隔 1.5 秒显示一个,录制过程大概是这样:开始录制 → 切换字幕 → 停止录制

本来以为挺简单的,结果录出来的视频有问题:

  • 录下来的视频里,只有背景图和第一句 字幕,视频很短 (大概 1 秒)。
  • 如果录制的时候手动拖一下文字 ,录出来的视频就一切正常了!

问题复现

我做了个CodeSandbox 示例

  1. 点 “Start Recording”,等字幕变化,然后下载视频。
  2. 再试一次,这次录制的时候用鼠标拖一下文字 ——视频就正常了!

查了半天,原因可能是...

Konva 画布的更新,可能没有及时同步到 MediaRecorderMediaStream 里。 MediaRecorder 录的还是老画面。 鼠标拖拽的时候, 因为是实时的用户交互,每次的改变都会被正确处理。但是, 当用代码改变画布时,可能由于性能原因,画布内容不会立刻更新到媒体流里.

我试过的方法 (都没用!)

为了让画布更新到 MediaStream,我试了这些:

  • clearCache(), draw(), batchDraw()
  • 模拟 dragstart, dragmove, dragend 事件
  • 每次更新字幕后,稍微改一下位置 (node.x(node.x() + 1))
  • toDataURL()保存为图片 (保存下来的图片字幕是对的,但录制下来的视频里字幕没有更新)

都没用。手动拖文字就能录,代码改就不行,实在搞不懂了。

解决方案 (几个可能有效的办法)

既然问题可能是画布更新不及时,那我们可以试试强制更新,或者换个思路。

1. 强制重绘 + requestAnimationFrame

requestAnimationFrame 来保证每次更新都能被捕捉到。

原理:

requestAnimationFrame 会在浏览器每次重绘之前调用,可以确保画布在下一帧渲染之前更新。 结合 Konva 的 batchDraw 批量处理绘图操作。

代码示例:

// 更新字幕的函数
const updateSubtitle = (newSubtitle) => {
  textRef.current.text(newSubtitle);

  // 使用 requestAnimationFrame 和 batchDraw
  function animate() {
      stageRef.current.batchDraw();
      requestAnimationFrame(animate);
  }
  animate();

};

// 录制循环中:
subtitles.forEach((subtitle, index) => {
  setTimeout(() => {
    updateSubtitle(subtitle);
     if (index === subtitles.length - 1) {
          setTimeout(() => {
            recorderRef.current.stop();
            setRecording(false);
            stageRef.current.batchDraw(); // 确保最后也更新
          }, 1500); //录制时长,匹配字幕的更新
     }
  }, index * 1500);
});

额外建议:

  • 可以加个节流函数 (throttle),限制 requestAnimationFrame 的调用频率,避免过于频繁的重绘。

2. 使用 canvas.captureStream()

直接从 Konva 的底层 canvas 元素获取 MediaStream

原理:

Konva 的 stage 对象内部有一个 content 属性,这个属性引用的是实际的 canvas 元素。 直接从这个 canvas 元素获取 stream,可能更直接。

代码示例:

// 在开始录制的地方修改:
const startRecording = () => {
  // 从 Konva stage 获取 canvas
  const canvas = stageRef.current.container().querySelector('canvas');
    // 检查 canvas 是否存在
  if (!canvas) {
    console.error("Canvas element not found!");
    return;
  }
  // 直接从 canvas 获取 stream
  const stream = canvas.captureStream(25); // 25 FPS
  const recorder = new MediaRecorder(stream, { mimeType: 'video/webm' });

  recorderRef.current = recorder;

    // 与原示例中的数据处理部分一致
  const data = [];

  recorder.ondataavailable = (event) => data.push(event.data);
  recorder.onstop = () => {
    const blob = new Blob(data, { type: 'video/webm' });
    setVideoURL(URL.createObjectURL(blob));
  };

  recorder.start();
  setRecording(true);

   // 与原示例一致
  subtitles.forEach((subtitle, index) => {
    setTimeout(() => {
      textRef.current.text(subtitle);
        stageRef.current.batchDraw();
        if (index === subtitles.length - 1) {
          setTimeout(() => {
            recorderRef.current.stop();
            setRecording(false);
           }, 1500);
        }

    }, index * 1500);
  });
};

3. 手动触发 “假” 拖拽 (Hack)

如果手动拖拽有效,我们可以模拟这个过程。

原理:

虽然不完美,但可以试试每隔一小段时间,稍微移动一下文字,模拟用户拖拽。

代码示例:


const updateSubtitle = (newSubtitle) => {
    textRef.current.text(newSubtitle);

    // “假” 拖拽
    let dragInterval = setInterval(() => {
        textRef.current.x(textRef.current.x() + 1);
        stageRef.current.batchDraw();
        textRef.current.x(textRef.current.x() - 1); //复原
        stageRef.current.batchDraw();
    }, 50); //50 毫秒动一下, 可自行微调.

     setTimeout(() => {
          clearInterval(dragInterval); //清除"假拖拽"
     }, 1400); // 在字幕出现的时间里持续“假”拖拽

  };
  // 与原示例中的循环调用部分保持一致
subtitles.forEach((subtitle, index) => {
  setTimeout(() => {
       updateSubtitle(subtitle);
     if (index === subtitles.length - 1) {
          setTimeout(() => {
            recorderRef.current.stop();
            setRecording(false);
          }, 1500);
        }
  }, index * 1500);
});

额外建议:

  • 这个方法比较 hacky,可能需要根据实际情况调整移动的距离和频率。

4. 降低帧率(如果可以接受)

尝试更低的帧率(例如 10 FPS 或者更低)。
原理 : 有时候由于性能或其他原因,如果更新过快,每一帧都送给编码器,反而有可能让其漏掉帧. 如果要求不是很高,降低帧率后,确保每个画面都能录到.

代码 :
在调用canvas.captureStream()或初始化 MediaRecorder 时修改帧率:

 const stream = canvas.captureStream(10); // 10 FPS, 原本25
  // 或在 MediaRecorder 构造函数里 (如果支持的话):
  // const recorder = new MediaRecorder(stream, { mimeType: 'video/webm', videoBitsPerSecond: xxx, frameRate: 10 });

5. 使用MediaStreamTrackGenerator (进阶,如果以上方法均无效)

这是比较高级的API,如果前面所有办法都没有生效,且录制对你有非常高的要求,必须确保每一帧都不丢失,那可以试试这个

原理 :

  • MediaStreamTrackGenerator 允许更细粒度地控制媒体流中的帧。 可以把 Konva 画布的每一帧都手动“喂”给 MediaStreamTrackGenerator, 然后 MediaRecorder 去录这个track.
  • MediaStreamTrackGenerator 目前还在实验性阶段,请务必确认浏览器兼容

代码 :

import { MediaStreamTrackGenerator } from 'media-stream-track-generator';

// ... 其他代码 ...

const startRecording = () => {
    const canvas = stageRef.current.container().querySelector('canvas');
    if (!canvas) {
        console.error("Canvas element not found!");
        return;
    }

    // 1. 创建一个 video track generator.
    const trackGenerator = new MediaStreamTrackGenerator({ kind: 'video' });

     // 2. 创建一个 MediaStream 并添加 track
    const stream = new MediaStream([trackGenerator]);

    const recorder = new MediaRecorder(stream, { mimeType: 'video/webm' });
    recorderRef.current = recorder;
    const data = [];

      recorder.ondataavailable = (event) => data.push(event.data);
      recorder.onstop = () => {
        const blob = new Blob(data, { type: 'video/webm' });
        setVideoURL(URL.createObjectURL(blob));
      };
      recorder.start();
     setRecording(true);

      // 使用 writable 属性访问 MediaStreamTrackGenerator 的底层 WritableStream
      const writer = trackGenerator.writable.getWriter();

       let frameCount = 0;

       const drawFrame = () => {

        // 创建 VideoFrame 对象.  duration 表示当前帧持续的时间,单位微秒
          const frame = new VideoFrame(canvas, {
           timestamp: frameCount * (1000000/ 25),  // 时间戳, 此处假设是 25 FPS
           duration: (1000000/ 25),

          });
          frameCount++;

         // 将 VideoFrame 写入 track.
           writer.write(frame);
            frame.close(); // 释放 VideoFrame 资源

      };

      subtitles.forEach((subtitle, index) => {
            setTimeout(() => {
                textRef.current.text(subtitle);
                stageRef.current.batchDraw(); //更新后要 batchdraw 一下.
                 drawFrame(); // 把画面塞进 track generator.
                if (index === subtitles.length - 1) {
                    setTimeout(() => {
                        recorderRef.current.stop();
                        setRecording(false);
                         writer.close();  // 停止后记得关掉 writer!
                      }, 1500);
                }

           }, index * 1500);
    });

};

重要注意事项:

  • 要正确处理 VideoFrame 的生命周期,使用 close()方法,避免内存泄露。
  • 确保 timestamp 是递增的。
  • 这个API在实验阶段, 考虑浏览器兼容。

上面这些方法,你可以根据你的项目情况,逐个试试,看看哪个效果最好。录视频这块,有时候就是得不断尝试,才能找到最合适的方案!