Files
acfunlive-remake/motion_test.go
T
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

124 lines
5.6 KiB
Go
Raw 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"
"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 定位锚点")
}
}