Files
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

102 lines
3.5 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 (
"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)
}
}