om 渲染器:把 osu!mania 回放渲染成 MP4 的三条路

在 QQ 里发一句 om 2467450,机器人把 osu!mania 谱面渲染成 1080p60 的 MP4。这篇讲它为什么用 WebCodecs 而不是 MediaRecorder、手写 MP4 封装踩过的坑,以及把这套东西搬到 2 核 VPS 上遇到的那几个 bug。

缘起

osu!mania 是下落式音游:音符从上方落下,在判定线被敲中的那一刻得分。想把一局游戏变成一段可以分享的视频,最直接的办法是录屏,但录屏有两个绕不过去的毛病——它锁死在实时,而且它录的是「正在播放的那个窗口」,不是「这首谱面」。

于是有了 om:在 QQ 里发一句 om 2467450,机器人把这张谱面渲染成一段 1920×1080 / 60fps 的 H.264 MP4 发回来。也可以回复一条带 .osr 回放文件的消息再发 om,渲染器就从回放里读键位,把真实的 Combo、CPS、误差条和判定面板画出来——没有回放时走 FC Sim,把所有音符当成完美命中。

输入是 osu!mania 的 .osu 谱面、谱面自带的音轨、可选的 .osr 回放;输出是带音轨的 MP4。

皮肤是真的皮肤

画面不是自己画的,是皮肤的。om 吃真实的 osu! .osk 皮肤包,解析里面的 skin.ini。一个 skin.ini 可以带好几个 [Mania] 段,靠 Keys: 区分(同一份皮肤可能同时写 4K 和 7K),渲染器必须挑出和谱面列数匹配的那一段——「文件里最后一段」不是一个合法的选择规则。

挑到之后,NoteImage{n} 是普通音符,NoteImage{n}H / L / T 是长条的头部、身体、尾巴,lightingN 和 lightingL 是判定线上的打击闪光。HUD 也是皮肤的一部分:Combo、CPS、进度条、谱面属性、BPM、准确率这六件,位置来自皮肤自带的 Layout JSON;皮肤没带就用内置的默认布局。

这里有一条项目自己立的规矩:不发明常量。每一个位置、每一个尺寸都要能指回 osu!lazer 的源码行或者 osu! wiki 的明确说法,指不回去就宁可留着问,不猜。皮肤的 480 坐标系、精灵像素的缩放系数、下落时间 TimeRange = 11485 / ScrollSpeed,都是从 lazer 那边一条条对出来的。好处是当画面和 lazer 不一致时,你有地方可查;坏处是这件事比想象中慢得多。

长条音符的绘制顺序(身体 → 头 → 尾,尾巴在最上)、尾巴在翻转时的朝向、音符精灵底部贴着判定线的那一刻即消失——这些都是可验证的细节,也都真的被验证过。

三条渲染路径

渲染器不是一份代码,是三份共享同一套皮肤语义的实现,存在理由各不相同。

路径 实现 出口 特点
Pillow 离线渲染 mania_render/renderer.py ffmpeg 直接吃 rawvideo 无浏览器依赖,能真地快过实时,服务器上跑的是它
网页渲染(人点导出) mania-web/,就是网页 UI 本身 浏览器里 WebCodecs 编码 + 页内封装 所见即所得
网页渲染(无头驱动) mania_render/webgl_render.py 编码流回传服务端,由 ffmpeg 封装 机器人得到的画面和人看到的一致

Pillow 那条路的做法很直接:不开任何窗口,把每一帧画成 1920×1080 的 RGB 原始字节,从 stdin 灌给 ffmpeg 编 H.264。因为几何固定死在 1920×1080,想要 720p 就是给 ffmpeg 加一个 scale——顺带成了超采样,而不是把每条轨道的宽度和判定线位置重排一遍。

workers = max(2, min(16, (os.cpu_count() or 4) - 2))
cmd = [
    ffmpeg, "-y",
    "-f", "rawvideo", "-pix_fmt", "rgb24",
    "-s", f"{VIEW_W}x{VIEW_H}", "-r", str(self.fps), "-i", "-",
    *audio_args,
    "-map", "0:v:0", "-map", "1:a:0",
    "-c:v", "libx264", "-preset", "ultrafast", "-threads", "0",
    "-pix_fmt", "yuv420p", "-r", str(self.fps),
    "-shortest", "-f", "mp4", "-movflags", "+faststart",
    str(out_path),
]

还有一个省下来的优化:画面完全静止、HUD 数值也没变的帧,直接复用上一帧的字节,不重新编码。谱面开头和间奏的大段空场因此几乎不花时间。

无头那条路走的是 CDP:Chromium 带 --remote-debugging-port 起来,附着上去,加载页面、选皮肤、传 .osr,然后点页面上的导出按钮。它的意义不在快,而在于它渲染的就是网页渲染器本身——视觉一致性那部分工作全是拿这个页面对着 lazer 校的,机器人沿用同一个页面,就不会出现「机器人发的视频和网页上看到的不一样」。

WebCodecs 为什么是关键

这是整个项目里最值得讲的一个决定。

网页端最早用的是 canvas.captureStream() 加 MediaRecorder。它能直接出 MP4/H.264,看起来够用,但它有一个架构上绕不过去的性质:它按墙上时钟采样画布。于是:

  • 3 分 08 秒的谱面,导出就一定要花 3 分 08 秒,一秒都省不下来;
  • 机器画不到 60fps 的时候它不会慢下来,它会丢帧——你拿到的是一段帧率塌掉的视频;
  • 它只看得到画布,音轨根本进不来。

VideoEncoder 不一样的地方在于:时间戳是我们自己给的。new VideoFrame() 里的 timestamp 和真实时间没有任何关系,它只是告诉编码器「这一帧属于视频的第几微秒」。渲染速度和视频时间因此彻底解耦——机器一秒能画 200 帧就是 200 帧,一秒只能画 6 帧也就是慢慢画,但交出来的永远是标准的 60fps。渲染快慢只决定你要等多久,不决定视频长什么样。

for (let f = 0; f < total; f++) {
  const t = f * 1000 / WC_FPS;          // chart time — our choice, not the clock
  drawFrame(t);
  cctx.drawImage(canvas, 0, 0, outW, outH);
  cctx.drawImage(hudCanvas, 0, 0, outW, outH);
  const frame = new VideoFrame(composite, {
    timestamp: Math.round(t * 1000),
    duration: Math.round(1e6 / WC_FPS),
  });
  encoder.encode(frame, { keyFrame: f % 120 === 0 });
  frame.close();
  if (encoder.encodeQueueSize > 24) await new Promise((r) => setTimeout(r, 0));
}

三个细节:drawFrame(t) 是拿我们给的时间点去画,不是读 audio.currentTime;encodeQueueSize 那道闸门是必须的,不排空队列等于在内存里堆两个小时的帧;编码器配置还有一处分叉——页内自己封装要用 AVCC(长度前缀)加 avcC 描述块,交给 ffmpeg 则要 avc: { format: 'annexb' }(起始码分隔)。同一个 VideoEncoder,两种字节布局,取决于后面接的是谁。

码率也是算出来的而不是写死的:90MB 除以时长,钳在 150kbps 到 6Mbps 之间,让结果落在 90MB 附近。固定码率会让长谱面产出没法发出去的文件——336 秒乘 12Mbps 大约是 470MB,而聊天软件对能直接播放的视频是有大小限制的。这条计算不是理论:它存在的唯一原因就是「视频渲染好了但发不出去」。

顺带说清楚一件事:页内自己封装那条路是给人用的(点了导出按钮,没有任务号);机器人那条路会把 Annex-B 裸流按 120 帧一批 POST 回服务端,由 ffmpeg 封装并配上音轨。之所以是流式而不是攒起来一起传,是因为 5 分钟的谱面大约有 200MB 编码数据,全部留在内存里本身就是个 bug。

自己写 MP4 封装

WebCodecs 交出来的是样本,不是容器。它给你一段段 H.264 数据,不会给你一个能播的文件。所以有了 mania-web/src/mp4.js:一个手写的 ISO-BMFF 封装器,直接写 ftyp、moov(mvhd 加每个轨道一个 trak)、mdat,没有任何 npm 依赖。视频轨时间基是 1/1000 秒,音频轨用采样率。

写盒子本身不难,难的是那些「文件看起来没问题,直到某个播放器拒绝它」的坑。下面几个都真的踩过,也都留在了代码注释里。

一、时间基不能整除帧长。 时间基用 1/1000 秒时,60fps 的帧长是 16666 微秒,只能凑成 17 毫秒;1000/17 = 58.8,文件于是声称自己比谱面还长,播起来还偏慢。改成 WC_FPS * 1000(60000)之后,一帧正好 1000 个 tick,不用凑。

二、chunk.numberOfFrames 是可选属性。 Chrome 的 AAC 编码器不填它,回来是 0。信了它,写出来的 stts 里两万多个音频样本的 delta 全是 0,轨道的 mdhd / tkhd 时长被清零,播放器认为所有音频都在 t=0——导出的文件音频快进。正确做法是用 chunk 的时间戳差值反推样本数,第一帧和最后一帧没有后继,退回 AAC 的 1024 帧长。

三、stco 只有一条记录等于没有索引。 一个轨道一个 chunk 在规范上合法,但没用:WMP 的 MP4 分离器用 stco 建「时间到字节」的映射,只有一条记录它定位不到任何时刻,索性把整个轨道丢掉。同一批音频字节,单 chunk 的文件在 WMP 里是静音的,ffmpeg -c copy 把 stco 重写成两万多条记录、交错两个轨道之后就能正常播。Chrome 宽容——它从 chunk 起点开始累加 stsz——所以同一个文件在浏览器里能拖能放,在 WMP 里是哑的。修法是视频按关键帧切 chunk(随机访问本来就需要一个同步样本),音频按固定 43 帧一组。

四、stco 和 stsc 必须描述同一批 chunk。 给 stco 每个样本列一条偏移,它就声称有 N 个 chunk,而 stsc 说的是 1 个;ffmpeg 会直接报 wrong sample count。这两个盒子是一对,改一个必须改另一个。

这几个坑有个共同点:它们都不会让你自己的浏览器报错。只有浏览器能放,才是最危险的信号。

部署到 2 核服务器学到的东西

目标机器是一台 2 核 / 1736MB 的 VPS,上面已经跑着一个 QQ 机器人(约 607MB 常驻)、NapCat,还有一个渲染服务,空闲内存在 600–860MB 之间。它跑不动 1080p60 的实时绘制,所以渲染被挪到了一台本地机器上,VPS 只做中转:

ssh -N -R 8760:127.0.0.1:8760 root@VPS

-R 是把远端的监听转发到本机的某个地址,意思是「在 VPS 的 8760 上监听,把连接交给运行 ssh 这台机器的 127.0.0.1:8760」——也就是本地那台。VPS 的 sshd 默认 GatewayPorts no,所以这个监听只绑在 VPS 的回环上(ss 里能看到 127.0.0.1:8760 和 [::1]:8760,公网 IP 直接拒绝连接),渲染服务没有被暴露出去。

几个刻意的决定:VPS 上那个渲染服务是主动停用的,免得它把隧道要用的端口抢回去;插件里没有服务端兜底渲染器,本地机器或者隧道挂了任务就直接失败——这是设计,不是待修的 bug。Windows 上没有 autossh,所以断线重连是自己写的循环:ServerAliveInterval=30 配 ServerAliveCountMax=3 让 ssh 自己在约 90 秒内发现链路死了,ExitOnForwardFailure=yes 保证它宁愿退出也不留在一个没有转发的状态,退避从 3 秒指数涨到 60 秒,一次活过 120 秒的连接会把退避重置。

然后是真正花时间的部分:把它搬到 Linux 上。下面几个 bug 都很具体,也都很好修——难的是发现它们。

一、写死的 curl.exe。 下载谱面、音频、背景全都 shell 调用 curl,而二进制名字是写死的 curl.exe。Linux 上没有这个名字,于是每次调用都死在 FileNotFoundError: No such file or directory: 'curl.exe'。更阴的是文本请求那条路有 urllib 兜底,把问题盖住了;但整包下载、音频下载、背景下载是直接调 curl 的,没有兜底——所以在 Windows 之外,音频和背景从来就没下载成功过。修法是按平台解析:

def _resolve_curl() -> str:
    names = ("curl.exe", "curl") if os.name == "nt" else ("curl", "curl.exe")
    for name in names:
        found = shutil.which(name)
        if found:
            return found
    raise RuntimeError(
        "curl is required by mania-render (every beatmap / audio / background "
        "download shells out to it) but was not found on PATH. Looked for: "
        f"{', '.join(names)}. Install it — Debian/Ubuntu: `apt install curl`, "
        "Alpine: `apk add curl`, RHEL/Fedora: `dnf install curl`."
    )

两处值得留下来:报错要说出缺的是哪个二进制、装它的命令是什么,而不是把一个光秃秃的 FileNotFoundError 丢进插件日志;解析是懒加载的,因为 --web 这条静态和 API 路径本身不下载任何东西,缺 curl 不该让它起不来——只有真的开始渲染才应该失败。

二、伪装的 User-Agent 被 Cloudflare 挡了。 原来的 UA 写成 Mozilla/5.0 (compatible; mania-render/0.1),看起来是「既表明身份又显得像浏览器」,结果 osu.ppy.sh 背后的 Cloudflare 从这台机房的 IP 直接回 403 挑战页。把所有会说话的站点都测了一遍(osz 用 2KB range 请求,只取头部):

User-Agent osu.ppy.sh sayobot nerinyan (osz) catboy osu.direct beatconnect
Mozilla/5.0 (compatible; mania-render/0.1)(原来) 403 200 200 200 200 200
mania-render/0.1(改用) 200 200 200 200 200 200
mania-render/0.1 (+https://…) 200 200 404 403 200 200
curl/7.81.0 200 200 200 403 200 200

两个发现:在这个机房 IP 上,装成浏览器才是被拦的原因(真的 Chrome UA 也一样 403);而那个看起来无害的 (+https://…) 后缀会弄坏两个镜像。封锁是认 UA 的,不是认谱面的:四个不同的谱面 ID 在伪装的 UA 下全部 403,在现在这个 UA 下全部 200。这也是为什么每个站点都单独测了一遍——只测一个就下结论,会得到一个恰好相反的错误答案。

三、cache/osu/ 这个目录不存在。 全新安装上它还没被创建,写 .osu 的时候直接报 No such file or directory: .../cache/osu/<bid>.osu——一台刚装好的机器连一张谱面都渲染不了,而这甚至不是权限问题,只是没人建过这个目录。同类问题在音频缓存上也有一份:直接镜像那条路因为底层会顺手建父目录所以看不出来,但整包 osz 兜底那条路是直接写目标文件的,在干净检出上会炸。修法是在每个写路径入口先建目录,让下游都能假定它存在。

四、--fail-with-body 会把错误页留在正常路径上。 这个组合很容易踩:它在失败时仍然把服务器的错误响应体写进 -o 指定的文件,尽管退出码是非零的。直接下载到最终路径,意味着那个 57 字节的 {"error":"invalid_id"} 就躺在目标位置上;而音频缓存是用通配符读回来的,于是这坨 JSON 从此被当作 audio/mpeg 送出去,这张谱面再也恢复不了。修法是先写 .part,只在成功时原子替换,失败路径在 finally 里清干净。

现状

渲染器本身还在长。已经完整实现的是「一个皮肤就是一套系统」那一整套:按 Keys: 选 [Mania] 段、长条的头身尾层级、打击闪光的淡入淡出与按键保持、HUD 六件套。缺的那一块也是明确的:皮肤没带打击特效文件时不会退回 osu!lazer 内置的默认皮肤,所以那种皮肤画不出打击效果——这一条写在文档里,没有假装它不存在。另外键数超过 10 会直接拒绝渲染,因为皮肤生态里没有可靠的 10K 以上配置,与其画错不如明说。

部署方面,om 现在是「本地渲染节点 + VPS 只做中转」的形状。它不漂亮,但在一台只有 2 核、还要养一个 QQ 机器人的机器上,这是少数几个能让 1080p60 渲染真正跑起来、又不把机器人推进 swap 的做法。

源码正在开源。想给自己的谱面渲染一段视频的话,om <谱面 ID> 就够了。