在 uosc_danmaku 中集成 pakkujs 弹幕合并算法
前言
最近搭建了一个 Openlist 站点,存了很多番剧,于是便开始爽爽看番了。
但是出现了一个小问题。
我是用 mpv 看番的,然后装了很有名的插件 uosc_danmaku,这样就可以在 mpv 里集成弹幕来提升影响自己的观感。但是由于刷屏的人有点烦,所以我想到了另一个 B 站网页版很有名的扩展 pakkujs。pakkujs 是一个专门用来合并弹幕的插件,但是它只支持网页版 b 站。那么一边是可以集成弹幕的插件,一边是可以合并弹幕的插件,于是我一拍即合,决定在 uosc_danmaku 里实现 pakkujs 的合并逻辑。
uosc_danmaku 的原理
uosc_danmaku 是 mpv 播放器的弹幕插件,核心工作流程如下:
- 数据获取:通过 弹弹 play API 查询番剧信息,获取弹幕数据
- 弹幕解析:将获取到的弹幕解析为内部数据结构,包含时间戳、文本、类型(滚动 / 固定 / 顶部)、颜色、字号等
- 渲染显示:基于 uosc UI 框架,在 mpv 的 OSD 层绘制弹幕,按时间戳触发,模拟滚动 / 固定效果
- 交互控制:通过 uosc 的 control bar 提供弹幕搜索、开关、样式调整等图形化操作
实际上 uosc_danmaku 已有了基础的合并功能(merge_tolerance),但其只能做精确匹配,而 pakku 可以检查谐音和文本编辑距离合并相似弹幕,所以迁移还是有意义的。(创新点说是)
Lua:一个轻量级的脚本语言
在开始讲移植之前,先简单聊聊 Lua。我之前对 Lua 的唯一印象就是 nvim 的配置文件,一直以为它就是个配置语言。直到这次移植 pakku 才发现,Lua 其实是一门正经的脚本语言。
几个有意思的特点:
- table:数组是 table,字典也是 table,甚至面向对象都是用 table 模拟的。
- 多返回值:函数可以同时返回多个值,比如
string.find直接返回匹配的起止位置(Can you C do that ?) - 1-based 索引:数组下标从 1 开始. (人类可以统一一个标准吗,我觉得从 - 1 开始也挺好的)
mpv 的脚本系统基于 Lua,所以 uosc_danmaku 整个项目都是 Lua 写的。
然后我就有一个疑问,就是脚本语言怎么会拿来给 nvim,mpv 这类东西写配置,不会拖慢性能吗?
这与 Lua 的运行方式有关。实际上有个叫 LuaJIT 的东西,它是 Lua 的一个替代实现,性能极其恐怖。核心在于基于追踪的即时编译:解释器会监控代码运行,发现热循环后把整个执行路径编译成高度优化的本地机器码,热点路径的性能接近手写 C。
而 mpv 内置的弹幕渲染是 C 写的,而 Lua 脚本层只负责数据处理和逻辑调度。即使弹幕数据量很大,LuaJIT 也能把关键路径编译成原生代码,所以用 Lua 写插件并不会成为瓶颈。
lua 的语法和 C 很像,大概长这样:
1 | local dict = { |
弹幕长什么样
弹幕数据的结构。uosc_danmaku 支持两种来源的弹幕格式:
- 最常见的弹幕文件格式是 xml,每条弹幕是一个
<d>标签:
1 | <d p="12.345,1,25,16777215,1234567890">我是一条弹幕</d> |
p (for parameter)是一个逗号分隔的列表,依次是:时间, 类型, 字号, 颜色, 时间戳。比如上面那条弹幕:在 12.345 秒出现、滚动弹幕(type=1)、25px 字号、白色(0xFFFFFF)。
- 弹弹 play API 返回的格式是 json,字段顺序稍有不同:
1 | {"c": "12.345,16777215,1,25", "m": "我是一条弹幕"} |
两种格式最终都会被解析为统一的 Lua table:
1 | { |
解析逻辑在 modules/parse.lua:331 的 parse_xml_danmaku 和 parse_json_danmaku 中。有了统一的数据结构,后面的合并算法就不需要关心数据来源了。
pakkujs 的合并原理复现
将 pakku.js 的合并算法移植到 Lua 时,主要做了以下工作:
文本预处理
这一步在于去除 “完结撒花” 和 “完结撒花!!!” 之间的区别。
在比较之前,先对弹幕文本做标准化处理(modules/pakku.lua:436 ):
- 去除尾部标点(
。,!~等) - 全角转半角(
1→1) - 合并多余空格
- 支持自定义正则替换规则(
forcelist)
预处理后,就基本削除了打字习惯的差异,能大幅提升后续相似度判定的准确率。
编辑距离:字符频率近似法
经典 Levenshtein 算法是 modules/pakku.lua:488):
1 | function M.edit_distance(s1, s2) |
统计每个字符出现的次数,然后比较两组频率的 L1 距离。这样复杂度降到 啊。
拼音距离
我内置了一个数千汉字的拼音字典 PINYIN_DICT(modules/pakku.lua:16),将每个汉字映射为拼音,并做了一定压缩,再用同样的字符频率方法比较。这样 "好看" 和 "好看啊" 即使字面不同,拼音层面也很接近,这样也顺遍解决了一些谐音替代的问题。
余弦相似度(Bigram)
将文本拆成 相邻字符对 构成向量,计算余弦相似度(modules/pakku.lua:522):
1 | function M.cosine_similarity(s1, s2) |
bigram 的好处是能捕获局部结构 ——"弹幕" 和 "弹幕啊" 共享 弹幕 这个 bigram,相似度很高。
滑动窗口聚类
核心逻辑在 cluster 函数(modules/pakku.lua:578)中,用一个 nearby 数组维护当前窗口内的候选组:
1 | for _, ir in ipairs(irs) do |
密度调控
合并后的弹幕会被打上 [xN] 标记,同时根据屏幕显示密度动态调整。
字号放大:合并数越多的弹幕,字号越大,让热门弹幕更醒目。放大系数是合并数的对数,上限为 2 倍:
10 条以内的合并不会放大字号,超过 10 条后按对数增长,最大 2 倍。合并 100 条的弹幕字号翻倍,合并 1000 条也是 2 倍。
密度计算:屏幕上的显示密度与文本长度的平方根成正比,与字号的 1.5 次方成正比:
当屏幕上累积的 display value 超过阈值时,会触发 ** 缩小(shrink)或丢弃(drop)** 策略 —— 密度超限时整体缩小字号,超限严重时直接跳过低优先级弹幕不渲染。
实际效果

还不错吧!
刹那,夏日悄然落幕