在 uosc_danmaku 中集成 pakkujs 弹幕合并算法

前言

最近搭建了一个 Openlist 站点,存了很多番剧,于是便开始爽爽看番了。

但是出现了一个小问题。

我是用 mpv 看番的,然后装了很有名的插件 uosc_danmaku,这样就可以在 mpv 里集成弹幕来提升影响自己的观感。但是由于刷屏的人有点烦,所以我想到了另一个 B 站网页版很有名的扩展 pakkujs。pakkujs 是一个专门用来合并弹幕的插件,但是它只支持网页版 b 站。那么一边是可以集成弹幕的插件,一边是可以合并弹幕的插件,于是我一拍即合,决定在 uosc_danmaku 里实现 pakkujs 的合并逻辑。

uosc_danmaku 的原理

uosc_danmaku 是 mpv 播放器的弹幕插件,核心工作流程如下:

  1. 数据获取:通过 弹弹 play API 查询番剧信息,获取弹幕数据
  2. 弹幕解析:将获取到的弹幕解析为内部数据结构,包含时间戳、文本、类型(滚动 / 固定 / 顶部)、颜色、字号等
  3. 渲染显示:基于 uosc UI 框架,在 mpv 的 OSD 层绘制弹幕,按时间戳触发,模拟滚动 / 固定效果
  4. 交互控制:通过 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
local dict = {
"Hello", -- (1-based)
"world", -- (2-based)
-- (ipair stop here cuz no index 3)
owner = "episvr", -- 键值对
count = 2 -- 键值对
}

local function lookup(t, name)
for i, v in ipairs(t) do -- 带 index 的 foreach,do…end
if v == name then
return i, v, true -- 多返回值
end
end
return nil, nil, false
end

local word = "Hello"
local idx, val, ok = lookup(dict, word)

if ok then
print("Task [" .. val .. "] is #" .. idx .. " (1-based!)")
else
print("Task not found.")
end

-- 混合遍历
for k, v in pairs(dict) do
print(k, v)
end

弹幕长什么样

弹幕数据的结构。uosc_danmaku 支持两种来源的弹幕格式:

  1. 最常见的弹幕文件格式是 xml,每条弹幕是一个 <d> 标签:
1
<d p="12.345,1,25,16777215,1234567890">我是一条弹幕</d>

p (for parameter)是一个逗号分隔的列表,依次是:时间, 类型, 字号, 颜色, 时间戳。比如上面那条弹幕:在 12.345 秒出现、滚动弹幕(type=1)、25px 字号、白色(0xFFFFFF)。

  1. 弹弹 play API 返回的格式是 json,字段顺序稍有不同:
1
{"c": "12.345,16777215,1,25", "m": "我是一条弹幕"}

两种格式最终都会被解析为统一的 Lua table:

1
2
3
4
5
6
7
{
time = 12.345,
type = 1,
size = 25,
color = 0xFFFFFF,
text = "我是一条弹幕"
}

解析逻辑在 modules/parse.lua:331parse_xml_danmakuparse_json_danmaku 中。有了统一的数据结构,后面的合并算法就不需要关心数据来源了。


pakkujs 的合并原理复现

将 pakku.js 的合并算法移植到 Lua 时,主要做了以下工作:

文本预处理

这一步在于去除 “完结撒花” 和 “完结撒花!!!” 之间的区别。

在比较之前,先对弹幕文本做标准化处理(modules/pakku.lua:436 ):

  • 去除尾部标点( ~ 等)
  • 全角转半角(1
  • 合并多余空格
  • 支持自定义正则替换规则(forcelist

预处理后,就基本削除了打字习惯的差异,能大幅提升后续相似度判定的准确率。

编辑距离:字符频率近似法

经典 Levenshtein 算法 的,对于弹幕这种短文本密集类型完全没必要。pakkujs 用字符频率差异来近似(modules/pakku.lua:488):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function M.edit_distance(s1, s2)
local f1 = char_freq(s1)
local f2 = char_freq(s2)
local dist = 0
local seen = {}
for c, v in pairs(f1) do
dist = dist + math.abs(v - (f2[c] or 0))
seen[c] = true
end
for c, v in pairs(f2) do
if not seen[c] then dist = dist + v end
end
return dist
end

统计每个字符出现的次数,然后比较两组频率的 L1 距离。这样复杂度降到 ,而且对弹幕场景足够准确 ——“哈哈哈” 和 “哈哈哈啊” 的差异就体现在多了一个

拼音距离

我内置了一个数千汉字的拼音字典 PINYIN_DICTmodules/pakku.lua:16),将每个汉字映射为拼音,并做了一定压缩,再用同样的字符频率方法比较。这样 "好看" 和 "好看啊" 即使字面不同,拼音层面也很接近,这样也顺遍解决了一些谐音替代的问题。

余弦相似度(Bigram)

将文本拆成 相邻字符对 构成向量,计算余弦相似度(modules/pakku.lua:522):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function M.cosine_similarity(s1, s2)
local function make_bigrams(s)
local grams = {}
local chars = utf8_chars(s)
for j = 1, #chars - 1 do
local g = chars[j] .. chars[j+1]
grams[g] = (grams[g] or 0) + 1
end
return grams
end
local g1 = make_bigrams(s1)
local g2 = make_bigrams(s2)
local dot, norm1, norm2 = 0, 0, 0
for g, v in pairs(g1) do
local v2 = g2[g] or 0
dot = dot + v * v2; norm1 = norm1 + v * v
end
for _, v in pairs(g2) do norm2 = norm2 + v * v end
if norm1 <= 0 or norm2 <= 0 then return 0 end
return math.floor(dot * dot / norm1 / norm2 * 100)
end

bigram 的好处是能捕获局部结构 ——"弹幕" 和 "弹幕啊" 共享 弹幕 这个 bigram,相似度很高。

滑动窗口聚类

核心逻辑在 cluster 函数(modules/pakku.lua:578)中,用一个 nearby 数组维护当前窗口内的候选组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
for _, ir in ipairs(irs) do
local dm_time_ms = (ir.dm.time or 0) * 1000
-- 移除过期的候选组
while #nearby > 0 do
if dm_time_ms - nearby[1].time_ms > threshold_ms then
clusters[#clusters + 1] = nearby[1]
table.remove(nearby, 1)
else break end
end
-- 从后往前扫描,找到相似的就归入
local matched = false
for ci = #nearby, 1, -1 do
local rep = nearby[ci].irs_list[1]
if check_similar(ir.str, ir.mode, rep.str, rep.mode, cfg) then
nearby[ci].irs_list[#nearby[ci].irs_list + 1] = ir
matched = true; break
end
end
if not matched then
nearby[#nearby + 1] = { irs_list = { ir }, time_ms = dm_time_ms }
end
end

密度调控

合并后的弹幕会被打上 [xN] 标记,同时根据屏幕显示密度动态调整。

字号放大:合并数越多的弹幕,字号越大,让热门弹幕更醒目。放大系数是合并数的对数,上限为 2 倍:

10 条以内的合并不会放大字号,超过 10 条后按对数增长,最大 2 倍。合并 100 条的弹幕字号翻倍,合并 1000 条也是 2 倍。

密度计算:屏幕上的显示密度与文本长度的平方根成正比,与字号的 1.5 次方成正比:

当屏幕上累积的 display value 超过阈值时,会触发 ** 缩小(shrink)丢弃(drop)** 策略 —— 密度超限时整体缩小字号,超限严重时直接跳过低优先级弹幕不渲染。

实际效果

弹幕合并效果

还不错吧!


刹那,夏日悄然落幕

その髪、指先に触れたら
若指尖能触碰你那发丝的话
世界が明日で終わってもいいさ
即使世界明天终结也无妨吧
この道すがら 嵐が吹けど
纵使前路有狂风暴雨肆虐
全てを越え あなたに会いにゆく
也要跨越一切 前去与你相会
その先に何が、何が私を待つんだ
在那前方究竟有什么 有什么在等待着我
得るのは自分の満足か、刹那に去る快楽か
得到的只是自我满足 还是转瞬即逝的娱乐
道の先で陽炎がゆらゆら揺れている
道路前方升腾的热浪正摇摇曳曳的晃动
今でも心がふらふら揺れている
时至今日我的心依然摇摇晃晃不能安定
あなたを思えば思うほどに揺る
越是思念着 心越是动摇
それは愛か弱さなのか、刹那
那究竟是爱?还是软弱呢?刹那之间
夏が終わっていく
夏天悄然落幕
あなたを愛して 私を愛している
我爱着你 也爱着此刻的自己
いつまでこうしている?
还要这样持续到何时?
夏が終わっていく
夏天走向终结