这次要解决的不是“让 mpv 在旁边播放一段补帧视频”,而是让补帧结果回到 Bilibili 原来的播放器表面。外置 mpv 的问题很直接:弹幕、进度条、清晰度切换、倍速和全屏都留在 Chromium 的原播放器里;即使视频能跑到 120 FPS,也会变成两套互相不认识的播放状态。最后做出来的版本保留原 video 作为音频和控制时钟,让补帧画面覆盖在同一个播放器区域里,弹幕 DOM 和原有交互不需要重造。

项目基于 msojocs/bilibili-linux,最终分支是 feat/svp-frame-interpolation。它不是对 AppImage 解包后塞一个启动脚本,而是普通源码构建的一部分:pnpm run build 会构建扩展、注入主进程代码,并在 Linux 有 C 构建环境时额外构建共享内存模块。Windows 不要求这个模块,仍然保留原始帧和 H.264 两条兼容路径。

0. 最终架构和没有绕开的边界

阶段

实际实现

为什么这样做

DASH 源选择

在播放器页记录 MSE codec,并从 playurl 返回中按当前清晰度和实际 codec 选择流

不能再假设数组第一项就是当前 AVC 流;清晰度、AVC/HEVC/AV1 和晚到的响应都会让这个假设失效

插帧进程

mpv + VapourSynth,SVPFlow、NVIDIA Optical Flow 和 RIFE 分别生成脚本

复用 SVP 已有插件与硬件能力,不造新的运动估计器

无损回传

Linux 优先 POSIX 共享内存环;其他平台优先 localhost 原始 I420 帧

两条路径都没有输出编码和 Chromium 的第二次视频解码

兼容回退

低延迟 fragmented MP4 + H.264

原始帧渲染或原生模块不可用时,仍能保持内嵌播放和弹幕

呈现与控制

原 video 继续播音频、保存倍速和进度;补帧 canvas/video 只接管画面

不复制 Bilibili 的控制条与弹幕生命周期

这里要先把“零拷贝”说清楚。SVP 的 VapourSynth filter 需要 copy-back 的系统内存帧,Chromium 最后还要把 YUV 上传到 WebGL 进行合成,所以这不是 GPU DMA 一路直通浏览器的零拷贝。共享内存做的是去掉最伤画质也最浪费资源的那段:不再把插帧结果压成 H.264,再由 Chromium 解码一次。Linux 路径仍有一次写入 ring、一次复用缓冲区读取和一次纹理上传,但没有二次编码和二次解码。

1. 先把源流选对:清晰度、编码和旧请求不能混

最初看起来像“切换清晰度后补帧没有跟上”,实际原因不是 mpv 没有重启,而是页面会同时存在多个 playurl 响应。旧响应可能在新的清晰度已经生效后才到达;同一个 quality 又可能同时给 AVC、HEVC 和 AV1。只按 quality 或直接取 dash.video[0],很容易把上一个视频、上一个清晰度甚至另一个 codec 交给 mpv。

现在在 src/extension/common/svp.ts 中维护请求序号、页面身份、quality、URL、时长、宽高和 codec。MSE 的 addSourceBuffer() 会记录实际启用的 codec;提取 DASH 时先筛当前 quality,再优先匹配该 codec family。收到晚于最新请求的响应会直接丢弃。换视频和换清晰度时,这些字段一起参与重启判定,旧进程只在新流真正可用后才让出画面。

当前播放流
  quality + codec + URL + duration + width/height
            │
            ├── 与现有流一致:保留进程和缓冲
            └── 任一关键字段改变:停止旧输出,按新流启动

这也是为什么不把“某次启动时播放器瞬间 pause”当作问题。切换 MSE source 时原播放器的瞬时事件本来就可能出现,真正需要修的是不能把它当成用户主动暂停。现在仅在稳定的用户暂停状态下同步暂停插帧,流切换和重建期间保持原音视频时钟正常前进。

2. 目标帧率不再只是一条全局 120

单个 120 FPS 输入框对 4K 和低分辨率都不合理。4K30 往 120 推,画质和吞吐都会很难看;4K60 本身已经足够;而 1080p30、720p30 往 120 推在这张 RTX 5070 Ti 上是更合理的体验。因此设置里保留固定目标 FPS,同时加入“按视频规格自动帧率”的矩阵。

源规格

默认目标

原因

4K,30 FPS

60 FPS

先保证稳定,不把 4K 的运动估计、内存带宽和上传全部压到 120

4K,60 FPS / 120 FPS

0,关闭补帧

源帧率已经足够高,避免无意义额外处理

1080p、720p、480p、360p,30/60/120 FPS

120 FPS

低于目标时才开启,保留用户可编辑的每格配置

矩阵按 4K、1080p、720p、480p、360p 和源帧率 30 / 60 / 120 三档区分,其中 0 的语义明确为“不补帧”。上限改成 600 FPS,不再暗中把用户输入钳在 240;真正能跑到多少由显示器刷新率、SVP 引擎、分辨率和当前播放器实际呈现决定。设置页的测量按钮会用当前引擎、解码器、队列、传输和渲染器,从显示器刷新率开始向下试,跳过 mpv 图初始化的预填充阶段,只在连续生成与呈现都稳定的采样窗口记录结果。

3. 共享内存不是“大 buffer”,而是有回压的固定帧环

原始帧 HTTP 可以避免二次编码,但 4K 高帧率下 localhost 的拷贝和 JavaScript 分帧会很快占满一个核。Linux 因此增加了小型 N-API 模块 native/svp-shm/svp_shm.c:主进程建立命名 POSIX shared-memory ring,mpv 的 rawvideo stdout 被按固定 frameBytes 写入;preload 打开同一个 ring,按原 video 的媒体时间取最新该显示的帧。

mpv rawvideo stdout
       │
       ├── fixed-size I420 / I420P10LE frame
       ▼
主进程:POSIX shm ring(固定 capacity,带 read/write sequence)
       │                                      │
       │ ring 满:暂停 mpv                    │ seek:丢弃过期帧并重新基准化
       ▼                                      ▼
preload:复用 Buffer → WebGL2 三平面 YUV texture → 原播放器 canvas

capacity 不是按“越大越好”定的。实现先计算 targetFps * bufferSeconds,再受 512 帧、系统内存的 1/16、/dev/shm 可用空间一半和 2 GiB 总预算共同限制。小于 4 帧直接拒绝启动。默认预缓冲是 4 秒;倍速时缓冲按 wall-clock 换算,3x 会预留约 12 秒媒体时间。producer 到高水位才暂停,低水位恢复,而不是把 mpv 永远限在 1x,因此预填充结束后仍能把吞吐交给真实瓶颈。

共享内存只在 Linux 的 native module 成功构建和 WebGL2 YUV renderer 可用时自动优先。显式选“共享内存”失败会保留原播放器并报清楚原因;自动模式才会按 shm → raw → h264 回退。Windows 不硬塞 Linux 模块,自动模式从 raw 开始。

4. 画面慢、换倍速卡住,不能靠暂停音频硬等

长时间播放后“声音在前、画面慢几秒”的根因不是一个固定 buffer 数值,而是输出画面和原 video 分别在不同的时间轴上累积小误差。H.264 路径读取的是另一个 video element;原始帧路径按原视频 currentTime 算帧序号。之前如果新管线没有追上,就暂停原播放器等待,短期看似没有丢帧,实际是用户最讨厌的卡顿和音画割裂。

  1. 误差在 120 ms 内时,只把输出播放率微调到原速度的 0.99 至 1.01 倍。

  2. 输出落后时先加速补上;落后超过 350 ms 时暂时显示原画面,音频继续走。

  3. 共享内存队列为空且落后持续时,向已有 mpv graph 发送 seek。目标时间会加上从前几次恢复中测得的首帧延迟,再要求一个小 reserve 才切回插帧画面。

这个过程会主动跳过已经不可能显示的 SVP 工作,而不是悄悄降低配置的目标 FPS。OSD 中会显示 fallback、seek catch-up、队列、stalls、dropped/skipped、音画误差和当前的 source / interpolation / output backend。用户看到的是暂时原画面,不是无响应的暂停按钮。

倍速更特殊。显示目标 FPS 不应该再乘 3,例如 120 FPS 的配置在 3x 仍以 120 为显示上限,实际媒体帧率会随倍速重新计算。VapourSynth script 的 media output FPS 不变时复用现有 graph;需要改变 script rate 时才重建。SVPFlow 的 scene.mode=2 在目标低于 2 倍源帧率时会直接报错,所以运行时根据 source / target 临时切到兼容 scene mode,设置里保存的 mode 不被改写,恢复倍速后自动回到原设定。

5. 硬件选择只给推荐,不能偷偷替用户切卡

这一台机器同时有 RTX 5070 Ti 和 Intel Arc 集显,最容易出现的误解是“选了 NVIDIA,为什么日志又出现 Intel”。SVPFlow 走 OpenCL,RIFE 走 Vulkan,mpv 解码、H.264 编码和 Chromium output decode 又各自是独立的选择。不能仅按系统默认 GPU 推断,也不能因为自动探测到某个设备就替用户保存它。

设置页因此分别提供 source decoder、SVPFlow OpenCL device、RIFE Vulkan GPU、output encoder、Chromium decoder。自动项只标出 (推荐),不覆盖用户已选项;显式选 NVDEC、VAAPI、QSV、AMF、NVENC 或 x264 时,无法启动就报该后端错误,只有 Auto 允许兼容回退。Linux Chromium 设备改动需要重启应用,启动失败会回滚到上一个已知可用配置;--svp-chromium-safe-mode 可以做一次不改配置的救援启动。

SVPFlow 的 GPU queue count 对应 SmoothFpsgpu_qn,是同时在飞的 OpenCL render request 数,不是“GPU 占用率目标”。运动分析阶段仍可能吃 CPU 和内存带宽,所以 GPU 只有几十个百分点并不说明继续加 queue 就能让 4K120 流畅。默认让 Auto 在高目标帧率使用 3,用户仍可以按实际显存和稳定性选择。

6. OSD 只做真实采样,不把预热尖峰当成性能

之前刚启动 mpv 时吞吐可能跳到 400% 或 800%,这只是预填充,不代表稳定帧生成能力。新的 OSD 把指标拆开,所有工作耗时用最近 0.5 秒的 active-work rolling window 计算,空闲时不把上一次的高值带到下一段播放。

OSD 项

含义

Pipeline

mpv 解码、插帧、输出的端到端实际周期;SVPFlow 没有通用 kernel timer,因此不伪造单独 GPU kernel 时间

SHM write / read / assemble / upload

共享内存写入、读取、帧组装和 WebGL 上传的单独毫秒数与占帧预算比例

queue / backpressure

当前帧数、容量、producer 被高水位暂停的次数和时长

present / display FPS

VideoFrame callback 与 rAF 的中位数估计,避免把浏览器空值显示成虚假的 0

system / mpv / renderer

CPU、内存、可用 GPU 指标,以及 dropped、skipped、stalls、A/V drift

OSD 默认关闭,设置里打开 Debug / OSD 后才叠加到画面。这避免普通用户一直看到调试面板,也让性能排查有完整上下文:知道究竟是 SVP 分析、输出编码、共享内存、WebGL 上传还是显示器刷新上限在拦住帧率。

7. 为了能提 PR,先把补帧代码从反编译代码旁边挪开

第一版实现把几乎所有主进程逻辑堆进 src/inject/common/electron-tool.ts,播放器和设置也各自变成一两千行组件。这样继续加一个 fallback 或 OSD 字段都会扩大冲突面,上游升级 Bilibili 反编译代码时尤其难合。

src/inject/common/electron-tool.ts
  └── 配置 Chromium、注册 IPC、观察 BrowserWindow

src/inject/svp/
  ├── service.ts       进程、流、共享内存 ring、buffer control
  ├── script.ts        SVPFlow / NVOF / RIFE VapourSynth script
  ├── runtime.ts       路径、GPU、Chromium decoder 回滚
  ├── capabilities.ts  mpv / 插件 / 设备探测
  ├── metrics.ts       CPU、GPU、显示器与进程采样
  └── runtime-ipc.ts   设置与运行态 IPC

src/extension/ui/player/svp/
  ├── startSvpStream.ts       启动、回退、流重选
  ├── outputs.ts / renderers.ts 原始帧与 WebGL / Canvas 输出
  ├── useSvpPlaybackRuntime.ts 时钟、fallback、catch-up
  ├── useSvpOsd.ts / osd.ts    OSD 格式与采样
  └── useSvpBenchmark.ts       当前真实播放链路测量

Linux 原生部分也保持可移除:native/svp-shm 只含一个 binding.gyp 和 C ring 实现,构建目录被 gitignore。没有 C 编译器时普通 pnpm run build 只警告并继续;pnpm test:svp-shm 则严格构建,验证 lifecycle、backpressure 和跨进程映射。最终提交不包含 app/dist/、AppImage、native build/ 或任何解包后的补丁文件。

8. 交付前实际检查

pnpm exec tsc -b --pretty false
pnpm test:svp-shm
pnpm run build
git diff --check

# 只打 AppImage,避免本机没有 rpmbuild 时把 RPM 缺失误判为构建失败
pnpm exec electron-builder --linux AppImage --x64 -c conf/build.json --publish never

共享内存测试输出为 svp-shm: lifecycle, backpressure and cross-process mapping passed;TypeScript、生产构建和空白字符检查均通过。仓库全量 ESLint 仍会命中已有的 res/electron.d.ts 与 protobuf 生成文件排序问题,因此对本次所有变更文件逐个执行 lint,保持新增和修改的代码为零错误,不为了通过一条本来就红的全库检查把上游生成代码整片格式化。

这次重构的重点不是把补帧做成一个看起来很炫的开关,而是把失败路径也当成播放器的一部分:找不到 SVP、RIFE 模型不匹配、共享内存模块缺失、WebGL 失败、H.264 编码器退出、切换清晰度或倍速时 graph 来不及追上,都应该让用户继续看到原视频、继续听到正常音频,并在 OSD 里得到可以复盘的数据。这样补帧是可选增强,不会反过来破坏 Bilibili 本来就能用的播放器。