Files
acfunlive-remake/command_len_test.go
tamakyi 6f3c8096b4 修分块渲染的弹幕轨迹:跨块续画 + 文字头像同源,并补失败推送与输出帧率设置
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。
2026-09-26 16:34:47 +08:00

87 lines
3.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"fmt"
"path/filepath"
"strings"
"testing"
)
// 按最坏情况(每条弹幕一个不同用户、每个都带头像、文字宽度顶满整屏)渲染一块,
// 返回这一块的命令行长度。长度从 renderChunk 自己的自检错误里读出来;
// 没触发自检就返回 (0, false)。
//
// 头像路径必须按 renderChunk 真正会拿到的形态造 —— 预缩放目录里的短相对路径
// (见 prepareAvatarInputs),不是 avatar_cache 里的长绝对路径;用后者量出来的是
// 一个早就不存在的旧版长度,会把上限算得过分保守。
//
// 线上的那次事故就是这里没守住:单块 200 条时实测 48288 字符,远超 Windows 的 32767,
// 而 exec 只回一句 "The filename or extension is too long",看不出跟弹幕数量有关。
func chunkCommandLenProbe(t *testing.T, n int) (length int, over bool) {
t.Helper()
dir := t.TempDir()
avatars := make(map[int64]string, n)
records := make([]danmuRecord, 0, n)
for i := 0; i < n; i++ {
uid := int64(1000000 + i)
avatars[uid] = chunkAvatarDir + "/" + fmt.Sprintf("%d.png", i+1)
records = append(records, danmuRecord{
Time: float64(i) * 0.05, UID: uid, Name: "观众甲",
Content: "这条弹幕稍微长一点测试宽度", Lane: i % 100, Width: 1920,
})
}
ctx := &burnContext{
ffmpegFile: filepath.Join(dir, "没有这个-ffmpeg.exe"), // 自检在 exec 之前,跑不到它
outFile: filepath.Join(dir, "out.mp4"),
meta: danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 42},
avatars: avatars,
dir: dir,
}
err := ctx.renderChunk(danmuChunk{start: 0, end: 60, records: records}, "chunk.ass", ctx.outFile, false, nil)
if err == nil {
t.Fatal("用不存在的 ffmpeg 路径不该成功")
}
if !strings.Contains(err.Error(), "命令过长") {
return 0, false
}
if _, scanErr := fmt.Sscanf(err.Error(), "ffmpeg 命令过长(%d 字符", &length); scanErr != nil {
t.Fatalf("没能从错误里读出长度:%v / %s", scanErr, err)
}
return length, true
}
// 按硬上限切出来的块,在最坏情况下也不能顶破命令行上限
func TestChunkCommandLengthUnderLimit(t *testing.T) {
if length, over := chunkCommandLenProbe(t, maxDanmuPerChunkHard); over {
t.Fatalf("按硬上限 %d 条渲染,命令长达 %d 字符,已经超过安全值 %d",
maxDanmuPerChunkHard, length, maxCommandLineLen)
}
}
// 块大到一定程度必须被自检拦住,并报出「多少条弹幕、多少个头像」这种能定位的信息
func TestRenderChunkReportsTooLongCommand(t *testing.T) {
length, over := chunkCommandLenProbe(t, 300)
if !over {
t.Fatal("300 条的最坏情况应当被自检拦住,否则交给 exec 只会报一句看不出原因的错")
}
if length <= maxCommandLineLen {
t.Fatalf("自检报出的长度 %d 应当大于阈值 %d", length, maxCommandLineLen)
}
}
// 硬上限本身要留出余量:真跑一遍「上限条数 + 最坏情况」,而不是拿一个会过期的
// 单条估算值乘一乘。换了头像方案(绝对路径 → 预缩放短路径)、滤镜写法或
// 单条 overlay 的表达式以后,这个用例会跟着一起变,不会悄悄失效
func TestChunkHardLimitLeavesHeadroom(t *testing.T) {
if length, over := chunkCommandLenProbe(t, maxDanmuPerChunkHard); over {
t.Fatalf("硬上限 %d 条的最坏情况实测 %d 字符,已经超过安全值 %d",
maxDanmuPerChunkHard, length, maxCommandLineLen)
}
// 再往上加一成也不该顶破安全值,防止上限被悄悄调高到刚好卡线
headroom := maxDanmuPerChunkHard / 10
if length, over := chunkCommandLenProbe(t, maxDanmuPerChunkHard+headroom); over {
t.Fatalf("上限再高 %d 条(共 %d 条)就顶破了安全值 %d 字符,余量太小",
headroom, maxDanmuPerChunkHard+headroom, length)
}
}