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。
102 lines
3.5 KiB
Go
102 lines
3.5 KiB
Go
package main
|
||
|
||
import (
|
||
"math"
|
||
"path/filepath"
|
||
"testing"
|
||
)
|
||
|
||
// 输出帧率只在目标的**上面**才生效:源片本来就 60fps 时填 30 只会把画面压得更卡,
|
||
// 源片帧率探测不到(0)时也不动,免得把高帧率源押到某个值
|
||
func TestOutputFpsOnlyRaises(t *testing.T) {
|
||
for _, c := range []struct {
|
||
target int
|
||
source float64
|
||
want int
|
||
}{
|
||
{0, 30, 0}, // 没填:跟随源片
|
||
{60, 0, 0}, // 源片帧率探测不到:不动
|
||
{60, 30, 60}, // 15/24/30fps 录播里弹幕一格一格跳,提上去
|
||
{60, 60, 0}, // 已经一样高:不动
|
||
{60, 59.94, 60}, // 29.97/59.94 这类小数帧率要当成「低一点点」
|
||
{30, 60, 0}, // 降帧没意义
|
||
{-1, 30, 0}, // 非法值当成没填
|
||
} {
|
||
if got := outputFps(c.target, c.source); got != c.want {
|
||
t.Errorf("outputFps(%d, %g) = %d,期望 %d", c.target, c.source, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 透明头像层模式没有源片可跟:设置填了就按填的,没填固定 30
|
||
func TestLayerFpsFallback(t *testing.T) {
|
||
if got := layerFps(0); got != defaultLayerFps {
|
||
t.Errorf("layerFps(0) = %d,期望 %d", got, defaultLayerFps)
|
||
}
|
||
if got := layerFps(60); got != 60 {
|
||
t.Errorf("layerFps(60) = %d,期望 60", got)
|
||
}
|
||
// 手工构造的 burnContext 没填这个字段时也不能把 r=0 交给 ffmpeg
|
||
// (color 源拿 r=0 会直接 EINVAL,症状是 exit status 0xffffffea)
|
||
ctx := &burnContext{}
|
||
if got := ctx.layerRate(); got != defaultLayerFps {
|
||
t.Errorf("没填 layerFps 的 burnContext 应当回落到 %d,实际 %d", defaultLayerFps, got)
|
||
}
|
||
ctx.layerFps = 25
|
||
if got := ctx.layerRate(); got != 25 {
|
||
t.Errorf("填了 layerFps 就按填的来,实际 %d", got)
|
||
}
|
||
}
|
||
|
||
// 低帧率源设了输出帧率时,成片帧率真的会提上去:
|
||
// fps 滤镜必须加在字幕/头像之前,弹幕才会按新帧率重绘;
|
||
// 加在之后只是把已经画好的几帧复制一遍,看起来还是卡
|
||
func TestRenderChunkRaisesFrameRate(t *testing.T) {
|
||
ffmpegFile := getFFmpeg()
|
||
if ffmpegFile == "" {
|
||
t.Skip("找不到 ffmpeg,跳过")
|
||
}
|
||
withJobFlags(t, true, false, false)
|
||
dir := t.TempDir()
|
||
|
||
src := filepath.Join(dir, "src.mp4")
|
||
if err := runFFmpegWith(ffmpegFile, []string{
|
||
"-y", "-f", "lavfi", "-i", "color=c=black:s=160x120:d=1:r=15",
|
||
"-c:v", "libx264", "-pix_fmt", "yuv420p", src,
|
||
}, dir, nil); err != nil {
|
||
t.Skipf("生成 15fps 测试视频失败,跳过:%v", err)
|
||
}
|
||
|
||
chunk := danmuChunk{start: 0, end: 1, records: []danmuRecord{
|
||
{Time: 0.1, UID: 7, Name: "甲", Content: "提帧测试", Lane: 0, Width: 100},
|
||
}}
|
||
ctx := &burnContext{
|
||
ffmpegFile: ffmpegFile,
|
||
videoFile: src,
|
||
outFile: filepath.Join(dir, "out.mp4"),
|
||
meta: danmuMeta{PlayResX: 160, PlayResY: 120, FontSize: 20},
|
||
avatars: map[int64]string{},
|
||
dir: dir,
|
||
encoder: "libx264",
|
||
outFps: outputFps(30, 15),
|
||
}
|
||
if ctx.outFps != 30 {
|
||
t.Fatalf("15fps 源设 30 应当产出 outFps=30,实际 %d", ctx.outFps)
|
||
}
|
||
|
||
assName := chunkTempPrefix + "fps_test.ass"
|
||
if err := writeChunkAss(filepath.Join(dir, assName), ctx.meta, chunk, 0,
|
||
avatarSize(ctx.meta.FontSize), avatarGap(ctx.meta.FontSize)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
out := filepath.Join(dir, "chunk.mp4")
|
||
if err := ctx.renderChunk(chunk, assName, out, true, nil); err != nil {
|
||
t.Fatalf("提帧渲染不该失败:%v", err)
|
||
}
|
||
info := probeVideoStream(ffmpegFile, out)
|
||
if math.Abs(info.fps-30) > 0.5 {
|
||
t.Fatalf("成片帧率应当是 30,实际 %.2f", info.fps)
|
||
}
|
||
}
|