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。
124 lines
5.6 KiB
Go
124 lines
5.6 KiB
Go
package main
|
||
|
||
import (
|
||
"math"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// avatarXExpr 产出的表达式就长这样:A-(A+T)*(t<±S>)/D
|
||
var avatarXExprRE = regexp.MustCompile(`^(\d+)-\((\d+)\+(\d+)\)\*\(t([+-]\d+\.\d+)\)/(\d+)$`)
|
||
|
||
// 按块内时间 t 求 avatarXExpr 的值。只认它自己产出的这一种形式;
|
||
// 表达式一改,这里的正则就会失配,用例会明确报出来
|
||
func evalAvatarXExpr(t *testing.T, expr string, tInChunk float64) float64 {
|
||
t.Helper()
|
||
m := avatarXExprRE.FindStringSubmatch(expr)
|
||
if m == nil {
|
||
t.Fatalf("头像 x 表达式的形式变了,这个用例要跟着改:%s", expr)
|
||
}
|
||
playResX, _ := strconv.Atoi(m[1])
|
||
base, _ := strconv.Atoi(m[2])
|
||
total, _ := strconv.Atoi(m[3])
|
||
shift, _ := strconv.ParseFloat(m[4], 64)
|
||
den, _ := strconv.ParseFloat(m[5], 64)
|
||
if base != playResX {
|
||
t.Fatalf("表达式里两个底数不一致:%s", expr)
|
||
}
|
||
if den != danmuShowSeconds {
|
||
t.Fatalf("表达式的分母 %g 和弹幕停留时长 %g 对不上:%s", den, danmuShowSeconds, expr)
|
||
}
|
||
// %+.3f 打出来的是带符号的偏移量,所以这里是 t + shift
|
||
return float64(playResX) - float64(playResX+total)*(tInChunk+shift)/den
|
||
}
|
||
|
||
// 文字层(libass 的 \move)和头像层(overlay 的 x 表达式)必须是同一条运动轨迹。
|
||
// 两边一旦各算各的,被块边界截断的弹幕就会出现「文字已经走到左边、头像还在半路上」——
|
||
// 线上看到的就是「头像在中途偏离弹幕轨迹」
|
||
func TestAvatarAndTextShareSameMotion(t *testing.T) {
|
||
const playResX, avPad, total = 1920, 40, 600
|
||
for _, t0 := range []float64{0, 0.5, 123.456} {
|
||
// 块起点取三种典型位置:就是弹幕起播那一刻、在它之前 3 秒(被截断的续画)、
|
||
// 以及一个很大的时间轴位置(验证大数下公式也不跑偏)
|
||
for _, chunkStart := range []float64{t0, t0 - 3, t0 + 2000} {
|
||
expr := avatarXExpr(playResX, total, chunkStart-t0)
|
||
for _, tAbs := range []float64{t0, t0 + 0.01, t0 + 1.5, t0 + 9.999, t0 + danmuShowSeconds} {
|
||
wantText := danmuTextX(playResX, avPad, total, tAbs, t0)
|
||
gotAv := evalAvatarXExpr(t, expr, tAbs-chunkStart)
|
||
if math.Abs(gotAv-(wantText-float64(avPad))) > 0.5 {
|
||
t.Fatalf("t0=%.3f 块起点=%.3f t=%.3f 时文字左边缘 %.2f、头像 %.2f,"+
|
||
"两者应当相差一个头像宽度 %d", t0, chunkStart, tAbs, wantText, gotAv, avPad)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 出发点和终点也要对:起播瞬间文字左边缘刚好在画面右侧外一个「头像+间隔」处
|
||
// (对应的头像左边缘正好贴着画面右边界),停留结束时整条刚好完全离开左边界 ——
|
||
// 起点是 playResX 出头的那点偏移,终点是 -total,行程正好 playResX+total
|
||
if got := danmuTextX(playResX, avPad, total, 0, 0); got != float64(playResX+avPad) {
|
||
t.Errorf("起播瞬间文字左边缘应当是 %d,实际 %.2f", playResX+avPad, got)
|
||
}
|
||
if got := danmuAvatarX(playResX, avPad, total, 0, 0); got != float64(playResX) {
|
||
t.Errorf("起播瞬间头像左边缘应当贴着画面右边界 %d,实际 %.2f", playResX, got)
|
||
}
|
||
if got := danmuTextX(playResX, avPad, total, danmuShowSeconds, 0); got != float64(avPad-total) {
|
||
t.Errorf("停留结束时文字左边缘应当是 %d,实际 %.2f", avPad-total, got)
|
||
}
|
||
if got := danmuAvatarX(playResX, avPad, total, danmuShowSeconds, 0); got != float64(-total) {
|
||
t.Errorf("停留结束时头像左边缘(整条的左端)应当是 %d,实际 %.2f", -total, got)
|
||
}
|
||
}
|
||
|
||
// 写进 ASS 的 \move 起止点必须按**绝对时间**算,而不是「截断之后的这一小段」:
|
||
// 拿截断后的时长当整段行程(旧实现:终点写死 -Width)等于把 10 秒的路挤进零点几秒,
|
||
// 文字会飞过去;分块渲染里 66.4% 的弹幕都被截断过,所以这个错法影响面极大
|
||
func TestWriteChunkAssKeepsAbsoluteTrajectory(t *testing.T) {
|
||
dir := t.TempDir()
|
||
meta := danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 42}
|
||
avW, avGap := avatarSize(meta.FontSize), avatarGap(meta.FontSize)
|
||
total := avW + avGap + 480
|
||
|
||
// 块从 100 秒开始,弹幕起播在 96 秒 —— 块头把它切掉了,只剩最后 6 秒可见
|
||
chunk := danmuChunk{start: 100, end: 130, records: []danmuRecord{
|
||
{Time: 96, UID: 7, Name: "甲", Content: "被块头截断的", Lane: 2, Width: 480},
|
||
}}
|
||
path := filepath.Join(dir, "chunk.ass")
|
||
if err := writeChunkAss(path, meta, chunk, 0, avW, avGap); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
m := regexp.MustCompile(`\\move\((-?\d+),(-?\d+),(-?\d+),(-?\d+)\)`).FindStringSubmatch(string(data))
|
||
if m == nil {
|
||
t.Fatalf("ASS 里找不到 \\move:\n%s", data)
|
||
}
|
||
nums := make([]int, 4)
|
||
for i := range nums {
|
||
nums[i], _ = strconv.Atoi(m[i+1])
|
||
}
|
||
|
||
t0 := 96.0
|
||
wantX1 := int(math.Round(danmuTextX(meta.PlayResX, avW+avGap, total, chunk.start, t0)))
|
||
wantX2 := int(math.Round(danmuTextX(meta.PlayResX, avW+avGap, total, t0+danmuShowSeconds, t0)))
|
||
if nums[0] != wantX1 || nums[2] != wantX2 {
|
||
t.Errorf("\\move 起止点应当是 (%d → %d),实际 (%d → %d);"+
|
||
"终点按截断后的时长算会把 10 秒的路挤进 %.1f 秒里,文字直接飞过去",
|
||
wantX1, wantX2, nums[0], nums[2], chunk.end-t0)
|
||
}
|
||
// 终点必须是「走完全程、整条离开左边界」的位置,不是文字宽度本身
|
||
if nums[2] != avW+avGap-total {
|
||
t.Errorf("停留结束时的终点应当是 %d(= avPad - total),实际 %d", avW+avGap-total, nums[2])
|
||
}
|
||
if strings.Contains(string(data), "{") && !strings.Contains(string(data), `\an7`) {
|
||
t.Error("对话行丢了 \\an7 定位锚点")
|
||
}
|
||
}
|