修分块渲染的弹幕轨迹:跨块续画 + 文字头像同源,并补失败推送与输出帧率设置
1. 密集弹幕时头像偏离轨迹、弹幕突然加速、之后一段时间屏幕空白
分块渲染的固有问题,三个毛病叠在一起:
- writeChunkAss 把 \move 的起止时间截断到块边界(End=min(t0+10, chunk.end)),
但 x2 写死成 -Width —— 等于把 10 秒的路挤进可见的那零点几秒里,文字直接飞过去。
而头像 overlay 的 x 一直是「固定 /10 秒 + 真实 t0」,于是同一时刻文字已经走到
左边、头像还在半路,看起来就是「头像掉队(移动变慢)」。
- 块尾还会被单块上限收紧成「下一条弹幕出现的时间」,最极端时窗口只剩 0.01 秒。
- 每块只画「本块新出现」的弹幕,跨块的续画没有:块头屏幕上只剩刚出现的几条,
要 10 秒才重新填满 —— 就是「弹幕突然加速,然后一段时间屏幕上什么都没有」。
改法:文字层改成按绝对时间算轨迹(danmuTextX / danmuAvatarX / avatarXExpr 同源),
任何一块里的文字速度都等于真实速度;分块器改成「续画(起播时间在 start-10 ~ start)
+ 本块新出现的」,块尾取「下一条弹幕的起播时间」,这样每条弹幕只在唯一一块里算
新出现,显示期间跨过的后面几块把它接着画,一帧都不缺。
代价是每块要多画一倍多的弹幕,所以顺手把头像一个「绝对路径 + 每人一行 scale」改成
「预缩放 + 短相对路径」(prepareAvatarInputs / scaleAvatarFile),每条从实测 241
字符降到约 130,单块上限才能从 120 抬到 200(200 条 ≈ 26600,仍低于自检阈值 30000
和 Windows 的 32767)。
2. 合并失败没有任何通知
notify.go 新增 mergeFail 事件与 notifyMergeFailure(),接在 autoMerge() 和手动合并的
失败分支上。正常结束、文件里没有弹幕、以及用户自己点的停止都不算失败,不推 ——
最后一种推「合并失败」会误导。配置项 notifyMergeFail 默认开,且和 notifyEnabled 一样
在 Unmarshal 之前预置 true,免得老设置文件升级后被静默关掉。
3. 低帧率录播里的弹幕一格一格跳
新增设置项 mergeFps,默认 0 = 跟随源片(什么都不做)。只在目标高于源片帧率时才加
fps 滤镜,而且必须加在字幕/头像之前 —— 加在之后只是把已经画好的几帧复制一遍,
看起来还是卡。上限 240。
4. 顺带
- -danmuburn 命令行模式不走 initialize(),exeDir 从来没被赋值:头像缓存会落到
「当前工作目录/avatar_cache」,旧版还会把这个相对路径写进滤镜,而 ffmpeg 的工作
目录是弹幕目录,报「打不开 avatar_cache\xxx.png」整块失败。
- cleanTempFiles 见到目录就 continue,预缩放头像那个临时文件夹永远清不掉。
- command_len_test 不再用会过期的「每条 241 字符」估算,改成真跑一遍最坏情况;
探针的头像路径也改成真实的短相对路径(原来量的是早已不存在的长绝对路径)。
- 新增 TestWebuiSettingFieldsMatchInputs:把页面里 JS 的 settingFields/numberFields
抠出来,跟所有 input/select/textarea 的 id 比对,漏登记的设置项会被抓住。
验证
- gofmt / go vet / go test(174 个)/ go test -race / staticcheck / GOOS=linux go build 全过。
- 反向验证 32 项全过:把每个修复临时退回,对应测试确实失败。
- 真实片段实测(2023AcFun 晚会 120 秒高峰段,689 条弹幕,10 秒窗口内最多同时 140 条):
旧 6 块 / 绘制 689 次 / 54.1% 的弹幕文字被加速(中位 2.12x,最差 500x)/
54.2% 有轨迹缺口(中位缺 5.29 秒)/ 78.3s;
新 5 块 / 绘制 984 次 / 加速 0% / 缺口 0% / 98.8s(+26%)。
对照帧里旧版在块尾前 0.1 秒屏幕已经全空,块头后 0.4 秒仍然全空;新版一直是满屏。
- 输出帧率:15fps 源配 mergeFps=60,成片 60fps,1 秒内 60 帧哈希互不相同(不是复制帧);
默认设置下成片仍是 15fps。
This commit is contained in:
+95
-16
@@ -26,29 +26,76 @@ func denseRecords(n int) []danmuRecord {
|
||||
return records
|
||||
}
|
||||
|
||||
// 单块条数必须被硬上限挡住,否则 -filter_complex 会撑爆命令行长度
|
||||
// 单块条数必须被硬上限挡住(续画 + 本块新出现的条数一起算),否则 -filter_complex
|
||||
// 会撑爆命令行长度。
|
||||
//
|
||||
// 「新出现」的那些条按顺序拼起来必须正好是原始列表:续画只是同一批弹幕在后面几块里
|
||||
// 接着画,不能算成新弹幕,更不能有哪一条一次都没算成新出现(那就整条丢了)
|
||||
func TestSplitDanmuChunksCapsBlockSize(t *testing.T) {
|
||||
records := denseRecords(600)
|
||||
chunks := splitDanmuChunks(records, 0, 0)
|
||||
if len(chunks) < 2 {
|
||||
t.Fatalf("这么多弹幕应当分成多块,实际 %d 块", len(chunks))
|
||||
}
|
||||
total := 0
|
||||
fresh := make([]danmuRecord, 0, len(records))
|
||||
for i, ch := range chunks {
|
||||
if n := len(ch.records); n > maxDanmuPerChunkHard {
|
||||
t.Fatalf("第 %d 块有 %d 条弹幕,超过硬上限 %d", i+1, n, maxDanmuPerChunkHard)
|
||||
}
|
||||
total += len(ch.records)
|
||||
if ch.carried < 0 || ch.carried > len(ch.records) {
|
||||
t.Fatalf("第 %d 块的 carried=%d 越界(这一块共 %d 条)", i+1, ch.carried, len(ch.records))
|
||||
}
|
||||
fresh = append(fresh, ch.records[ch.carried:]...)
|
||||
}
|
||||
if total != len(records) {
|
||||
t.Fatalf("分块把弹幕弄丢或弄重了:%d -> %d", len(records), total)
|
||||
if len(fresh) != len(records) {
|
||||
t.Fatalf("新出现的弹幕总数应当是 %d,实际 %d", len(records), len(fresh))
|
||||
}
|
||||
for i := range records {
|
||||
if fresh[i].Time != records[i].Time || fresh[i].Content != records[i].Content {
|
||||
t.Fatalf("第 %d 条新弹幕对不上:拿到 %+v,期望 %+v", i, fresh[i], records[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 硬上限会把一部分弹幕挤到下一块,这些弹幕在下一块里仍要从自己的时间点开始显示:
|
||||
// 块尾不收紧的话,下一块的块头会落在它们后面,开头就被吃掉了
|
||||
func TestSplitDanmuChunksLosesNoDanmu(t *testing.T) {
|
||||
records := denseRecords(600)
|
||||
// 一条弹幕停留 10 秒,块边界经常从它中间切过去:分块器必须把它「续画」到后面几块里。
|
||||
// 不续画的话每个块头屏幕上只剩刚出现的那几条,要 10 秒才重新填满 —— 观众看到的就是
|
||||
// 「弹幕突然加速冲过去,然后一段时间屏幕上什么都没有」
|
||||
func TestSplitDanmuChunksCarriesOnScreenDanmu(t *testing.T) {
|
||||
records := denseRecords(600) // 每 0.02 秒一条,10 秒窗口里同时有 500 条
|
||||
chunks := splitDanmuChunks(records, 0, 0)
|
||||
|
||||
carriedTotal, fresh := 0, 0
|
||||
for i, ch := range chunks {
|
||||
carriedTotal += ch.carried
|
||||
fresh += len(ch.records) - ch.carried
|
||||
for _, r := range ch.records[:ch.carried] {
|
||||
// 续画只能是「上几块里已经出现过、此刻还在屏上」的那些
|
||||
if r.Time >= ch.start {
|
||||
t.Fatalf("第 %d 块(起点 %.3f)把时间 %.3f 的弹幕当成了续画,它不是上一块留下来的",
|
||||
i+1, ch.start, r.Time)
|
||||
}
|
||||
if r.Time < ch.start-danmuShowSeconds {
|
||||
t.Fatalf("第 %d 块(起点 %.3f)续画了时间 %.3f 的弹幕,它早就滚出屏了",
|
||||
i+1, ch.start, r.Time)
|
||||
}
|
||||
}
|
||||
}
|
||||
if carriedTotal == 0 {
|
||||
t.Fatal("这么密的弹幕不可能完全没有需要续画的块,续画逻辑没生效")
|
||||
}
|
||||
if fresh != len(records) {
|
||||
t.Fatalf("新出现的弹幕数不对:%d,期望 %d", fresh, len(records))
|
||||
}
|
||||
}
|
||||
|
||||
// 块与块必须首尾相接,而且每条弹幕的 10 秒停留窗口要在所有块里被完整画满:
|
||||
// 中间只要有缺口,观众就会看到那条弹幕闪一下又消失
|
||||
func TestSplitDanmuChunksCoversEveryShowWindow(t *testing.T) {
|
||||
// 每 0.5 秒一条:10 秒窗口里同时有 20 条,续画远放得下,不会触发「挤掉最早的几条」
|
||||
records := make([]danmuRecord, 600)
|
||||
for i := range records {
|
||||
records[i] = danmuRecord{Time: float64(i) * 0.5, UID: 1, Name: "甲", Content: "弹幕", Lane: i % 10}
|
||||
}
|
||||
chunks := splitDanmuChunks(records, 0, 0)
|
||||
|
||||
for i := 1; i < len(chunks); i++ {
|
||||
@@ -58,15 +105,47 @@ func TestSplitDanmuChunksLosesNoDanmu(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 按 writeChunkAss 的可见区间算法检查:每条弹幕在它所在的那一块里都要有完整的显示区间
|
||||
for _, ch := range chunks {
|
||||
for _, r := range ch.records {
|
||||
if vs := math.Max(r.Time, ch.start); vs > r.Time+1e-9 {
|
||||
t.Fatalf("时间 %.3f 的弹幕在所在块里从 %.3f 才开始,开头被块头截掉了", r.Time, vs)
|
||||
for _, r := range records {
|
||||
covered := r.Time
|
||||
for _, ch := range chunks {
|
||||
vs, ve, ok := danmuVisibleInChunk(r, 0, ch)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if ve := math.Min(r.Time+danmuShowSeconds, ch.end); ve <= r.Time+1e-9 {
|
||||
t.Fatalf("时间 %.3f 的弹幕在所有块里都没有显示区间,会被整条丢掉", r.Time)
|
||||
if vs > covered+1e-9 {
|
||||
t.Fatalf("时间 %.3f 的弹幕在 %.3f~%.3f 之间没被画出来(前后两块没接上)",
|
||||
r.Time, covered, vs)
|
||||
}
|
||||
if ve > covered {
|
||||
covered = ve
|
||||
}
|
||||
}
|
||||
if covered < r.Time+danmuShowSeconds-1e-9 {
|
||||
t.Fatalf("时间 %.3f 的弹幕只画到 %.3f,10 秒停留窗口没画满", r.Time, covered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 同一时刻挤了一堆弹幕(时间戳重复)时块尾会和块头重合,那种块渲染出来一帧都没有,
|
||||
// 拼进成片就是一段黑屏/缺帧
|
||||
func TestSplitDanmuChunksNeverProducesEmptySpan(t *testing.T) {
|
||||
records := make([]danmuRecord, 0, 400)
|
||||
for i := 0; i < 400; i++ {
|
||||
records = append(records, danmuRecord{
|
||||
Time: 5, UID: 1, Name: "甲", Content: "同一时刻", Lane: i % 10, Width: 100,
|
||||
})
|
||||
}
|
||||
chunks := splitDanmuChunks(records, 0, 0)
|
||||
if len(chunks) < 2 {
|
||||
t.Fatalf("400 条应当分成多块,实际 %d 块", len(chunks))
|
||||
}
|
||||
for i, ch := range chunks {
|
||||
if math.IsInf(ch.end, 1) {
|
||||
continue
|
||||
}
|
||||
if ch.end <= ch.start {
|
||||
t.Fatalf("第 %d 块的时间范围是空的(%.3f ~ %.3f),渲染出来一帧都没有",
|
||||
i+1, ch.start, ch.end)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user