React Konva MediaRecorder 字幕不同步? 5个解决方案
2025-03-13 09:24:03
React + Konva + MediaRecorder: 录制字幕不同步?试试这些办法!
最近做个小项目,用react-konva
画图,再用MediaRecorder
录下来做成视频。有个需求是加字幕——一个字符串数组,每隔 1.5 秒显示一个,录制过程大概是这样:开始录制 → 切换字幕 → 停止录制。
本来以为挺简单的,结果录出来的视频有问题:
- 录下来的视频里,只有背景图和第一句 字幕,视频很短 (大概 1 秒)。
- 如果录制的时候手动拖一下文字 ,录出来的视频就一切正常了!
问题复现
我做了个CodeSandbox 示例:
- 点 “Start Recording”,等字幕变化,然后下载视频。
- 再试一次,这次录制的时候用鼠标拖一下文字 ——视频就正常了!
查了半天,原因可能是...
Konva
画布的更新,可能没有及时同步到 MediaRecorder
的 MediaStream
里。 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在实验阶段, 考虑浏览器兼容。
上面这些方法,你可以根据你的项目情况,逐个试试,看看哪个效果最好。录视频这块,有时候就是得不断尝试,才能找到最合适的方案!