超新星:无服务器将浏览器 canvas 录为 MP4 的 10 个踩坑点
サーバーなしで canvas を mp4 に。詰まった10の点
我把浏览器游戏的演出在不使用服务器的情况下制作成 mp4,并使其能够通过手机的分享菜单发布到 X 上。虽然只是在 canvas 上绘制并用 MediaRecorder 录制,但遇到了“第一帧全黑”“字体自动改变”“webm 无法上传到 X”等 10 个不会报错的陷阱。本文在简要说明机制之后,会列出这 10 个陷阱及修复方法,最后汇总成一张检查清单。

我们正在制作的是棋盘游戏「超新星」,当星星达到4段时就会爆炸,并把周围的星星变成自己的颜色。爆炸引发下一次爆炸的“连锁”,可以在对局结束后生成数秒的视频。22连击(约7秒)大约为1.1MB。
机制
绘制1帧的函数,我设定为仅由时刻 t(秒)就能决定画面的函数。
/** 時刻 t(秒)の1枚を描く */
export function drawFrame(ctx: CanvasRenderingContext2D, c: Clip, t: number) { ... }
Clip 只有“落子前的棋盘”和“落子后的棋盘”以及棋盘的大小。爆炸的顺序包含在游戏引擎给出的结果中,所以只要 t 确定,哪个格子对应哪种形态也就确定了。
录制方只需用 requestAnimationFrame 持续调用它即可。
const canvas = Object.assign(document.createElement('canvas'), { width: 720, height: 900 });
const ctx = canvas.getContext('2d')!;
drawFrame(ctx, c, 0);
const stream = canvas.captureStream(30);
const rec = new MediaRecorder(stream, { mimeType: type, videoBitsPerSecond: 4_000_000 });
const chunks: Blob[] = [];
rec.ondataavailable = e => { if (e.data.size) chunks.push(e.data); };
const stopped = new Promise<void>(ok => { rec.onstop = () => ok(); });
rec.start(250);
const t0 = performance.now();
await new Promise<void>(ok => {
const step = () => {
const t = (performance.now() - t0) / 1000;
drawFrame(ctx, c, Math.min(t, len));
if (t >= len + .1) ok(); else requestAnimationFrame(step);
};
requestAnimationFrame(step);
});
rec.stop();
await stopped;
stream.getTracks().forEach(tr => tr.stop());
const blob = new Blob(chunks, { type: type.split(';')[0] });
以下是遇到的10个难点。
录制前
1. 画面的动效无法直接录制
棋盘由 DOM 组件和 CSS 动画驱动。没有将页面中部分 DOM 转为视频的 API。getDisplayMedia 会弹出请求屏幕共享权限的对话框,因此不适合用作“转为视频”按钮。
修复方法:将同样的流程重绘到 canvas 上。由于会绘制两遍,输出爆炸间隔(NOVA_STEP = 0.14秒)和动效时长的函数,应与屏幕上棋盘的相同函数保持一致,直接加载使用。修改屏幕上的动效时,视频也会随之变化。
2. 绘制函数依赖“上一帧”时,就无法验证
在逐帧继承前一帧状态来绘制的设计中,无法单独查看中间的某一帧。
修正方法:将 drawFrame(ctx, clip, t) 改为仅以时间作为参数的函数。在 drawFrame(ctx, clip, 1.3) 之后,通过 canvas.toDataURL() 即可获得该时刻的 PNG。可以用 Playwright 导出几张图来目视确认,宣传视频也是用同一个函数生成的。
3. 如果不加载字体,就会用其他字体绘制
即使你在 ctx.font 中写入了 Web 字体名称,如果该字体尚未加载,也会以替代字体渲染。不会抛出错误。页面上未使用的字体或粗细均未被加载。
此外,Google Fonts 的日文字体按字符范围(unicode-range)进行细分分发。仅凭字体名称加载,并不保证你所用字符所在的片段已被加载。
修正方法:在渲染之前,将实际要使用的字符传入以加载。
await Promise.all(["64px 'Dela Gothic One'", "500 24px 'Zen Kaku Gothic New'", "700 24px 'Zen Kaku Gothic New'"]
.map(f => document.fonts.load(f, '超新星連鎖!ブラウザで無料遊べるボードゲーム0123456789' + extra)));
只有包含传入 document.fonts.load() 第二个参数的字符的部分才会被读取。内容会变化的字符串作为 extra 添加。
录制时
4. 可写入的格式因浏览器而异。webm 无法发布到 X
修复方法:从 mp4 开始依次用 MediaRecorder.isTypeSupported 尝试。
function mimeType(): string | null {
if (typeof MediaRecorder === 'undefined' || typeof HTMLCanvasElement.prototype.captureStream !== 'function') return null;
return ['video/mp4;codecs=avc1.42E01E', 'video/mp4', 'video/webm;codecs=vp9', 'video/webm']
.find(t => MediaRecorder.isTypeSupported(t)) ?? null;
}
当格式为 webm 时,会显示「如果无法上传到 X 等平台,请使用 Chrome 或 Safari 重新制作」。在无法写入任何格式的终端上,则完全不显示按钮。
5. 第一帧变成全黑
captureStream() 被调用时,如果 canvas 上还没有画任何东西,视频的开头就会是一个空帧。
修复方法:在 captureStream() 之前,用 drawFrame(ctx, c, 0) 先把第一帧画好。
6. 录制是实时的。如果按帧号推进,视频的时间就会错位
MediaRecorder 是按实际流逝的时间来录制的。一段 7 秒的视频就需要 7 秒。如果设计成"每次调用推进 1/30 秒",当绘制延迟导致跳帧时,动画就会变成慢速播放。
修复方法:从 performance.now() 中调用 t(见上方代码)。等待期间,在按钮上显示"正在制作视频 40%"这样的进度。
7. 将标签页切到后台后,画面会停止
当标签页切换到后台时,requestAnimationFrame 会停止,在此期间视频画面也会停止。目前的实现中尚未采取应对措施。由于只需几秒钟即可完成,因此假定在制作过程中用户会注视屏幕。
录制完成后
8. 如果不等待录制结束就创建 Blob,结尾部分会缺失
调用 rec.stop() 之后,最后的 dataavailable 尚未到达。
修正方法:将 onstop 改为 Promise 并等待其完成后再创建 Blob。之后用 stream.getTracks().forEach(t => t.stop()) 也停止视频流的传递。Blob 的类型从 video/mp4;codecs=... 中去掉 codecs,改为 video/mp4,与文件名扩展保持一致。
9. 是否能共享,通过传递文件来确认
即使存在 navigator.share,也不一定能共享文件。
修复方法:用 navigator.canShare({ files: [file] }) 确认,不行的话就改成下载链接。
const file = new File([blob], `choushinsei-${chain}chain.mp4`, { type: blob.type });
if (navigator.canShare?.({ files: [file] })) {
await navigator.share({ files: [file], text }); // スマホなら、X や LINE を選べる
} else {
a.href = URL.createObjectURL(blob); a.download = file.name; // パソコンなど
}
10. 显示的 <video> 不会自动播放
自动播放,不静音就会被阻止。即使后来用 JavaScript 调用 setAttribute('muted', ''),video.muted 属性仍保持 false(该属性仅表示初始值 defaultMuted)。
修复方法:以属性形式添加 muted 和 playsinline,再写入 video.muted = true。关闭后用 URL.revokeObjectURL() 进行清理。
检查清单
| # | 要确认的事项 | 修复方法 |
|---|---|---|
| 1 | 是否试图录制 DOM 动画效果 | 在 canvas 上重绘。时间常量与画面共享 |
| 2 | 能否绘制任意时刻的一帧 |
将 drawFrame(ctx, clip, t) 变为仅关于时刻的函数 |
| 3 | 是否已加载仅在视频中出现的字体/字符? | document.fonts.load(font, 使う字) |
| 4 | 是否优先尝试 mp4? |
isTypeSupported 按 mp4 → webm 的顺序。若为 webm 则显示警告 |
| 5 | 第一帧是否为空? |
在 captureStream 之前绘制一帧 |
| 6 | 时间是否在按实时推进? |
从 performance.now() 到 t 的输出。显示推进程度。 |
| 7 | 将标签页切到后台时会发生什么? | 是默认它会停止,还是采取对策? |
| 8 | 是否等待了录制结束? |
等 onstop 完成后再处理 Blob。停止轨道。 |
| 9 | 是否确认过能否共享文件 |
canShare({ files }),不行的话就下载 |
| 10 | 预览是否会自动播放 |
muted 在属性和特性两方面 |
还有一件与技术无关、我另外决定的事。视频里只放棋盘、连锁数和网站地址,没有放玩家名字和对局编号。这是为了避免在线对弈中对手的名字不知不觉间被发到 SNS 上。
总结
- DOM 的特效无法录制,所以改用 canvas 重新绘制,通过
captureStream+MediaRecorder来录制 - 把绘制一帧的函数变为「关于时刻
t的函数」后,就能让画面与时间对齐,验证起来也更轻松 - 优先使用 mp4,并确认首帧、字体、清理工作以及是否可共享
如果有人试过其他方法(例如用 WebCodecs 逐帧写出等),请在评论区告诉我。上一期写的是「不接收电子邮件地址的 Google 登录」。
游戏在浏览器中基本免费,做出连锁后可以从结果画面生成视频。
来源:zenn · 游戏开发社区 · zenn.dev