Files
acfunlive-remake/webui_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

450 lines
15 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.
// web UI 相关
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"regexp"
"testing"
"time"
)
// 测试期间换一个管理口令
func withAuthToken(t *testing.T, token string) {
t.Helper()
streamers.Lock()
old := config.AuthToken
config.AuthToken = token
streamers.Unlock()
t.Cleanup(func() {
streamers.Lock()
config.AuthToken = old
streamers.Unlock()
})
}
// 页面上发给服务端的 X-Auth-Token 是 encodeURIComponent 过的口令,
// 这里用 url.QueryEscape 造出同样的形状(对中文而言两者产出一致)
func pageHeader(r *http.Request, token string) *http.Request {
r.Header.Set("X-Auth-Token", url.QueryEscape(token))
return r
}
func TestCheckAuthTokenPlainASCII(t *testing.T) {
withAuthToken(t, "a1b2c3d4e5f6")
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("X-Auth-Token", "a1b2c3d4e5f6")
if !checkAuthToken(r) {
t.Fatal("ASCII 口令应该通过")
}
// 带 % 的口令:页面会编码成 %25,服务端解码后要能对上
withAuthToken(t, "ab%20cd")
r = pageHeader(httptest.NewRequest(http.MethodGet, "/api/status", nil), "ab%20cd")
if r.Header.Get("X-Auth-Token") != "ab%2520cd" {
t.Fatalf("编码结果不对:%q", r.Header.Get("X-Auth-Token"))
}
if !checkAuthToken(r) {
t.Fatal("含 % 的口令应该通过")
}
}
func TestCheckAuthTokenNonASCII(t *testing.T) {
// 中文口令是最容易踩的:原先直接把中文塞进请求头,浏览器会抛
// TypeError(Failed to read the 'headers' property…),请求根本发不出去,
// 页面上只显示「无法连接到服务器」,用户被彻底锁在门外
withAuthToken(t, "我的口令abc123")
r := pageHeader(httptest.NewRequest(http.MethodGet, "/api/status", nil), "我的口令abc123")
if r.Header.Get("X-Auth-Token") == "我的口令abc123" {
t.Fatal("测试前提不成立:头里不该是原文")
}
if !checkAuthToken(r) {
t.Fatal("编码后的中文口令应该通过")
}
// 手写 curl 按文档原样发中文口令,也要能通过
r = httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("X-Auth-Token", "我的口令abc123")
if !checkAuthToken(r) {
t.Fatal("原样的中文口令也应该通过")
}
}
func TestCheckAuthTokenAllSources(t *testing.T) {
withAuthToken(t, "口令abc123")
// Authorization: Bearer
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("Authorization", "Bearer "+url.QueryEscape("口令abc123"))
if !checkAuthToken(r) {
t.Fatal("Bearer 写法应该通过")
}
// ?token=(net/http 已经把查询串解码好了)
r = httptest.NewRequest(http.MethodGet, "/api/status?token="+url.QueryEscape("口令abc123"), nil)
if !checkAuthToken(r) {
t.Fatal("查询参数写法应该通过")
}
// 下载链接里的 ?token= 也是这个路径,混着 % 的口令同样要对上
withAuthToken(t, "口令%20abc")
r = httptest.NewRequest(http.MethodGet, "/api/file?token="+url.QueryEscape("口令%20abc"), nil)
if !checkAuthToken(r) {
t.Fatal("查询参数里的混合口令应该通过")
}
}
func TestCheckAuthTokenRejects(t *testing.T) {
withAuthToken(t, "正确的口令")
cases := map[string]*http.Request{
"口令错了": func() *http.Request {
return pageHeader(httptest.NewRequest(http.MethodGet, "/api/status", nil), "错误的口令")
}(),
"没带口令": httptest.NewRequest(http.MethodGet, "/api/status", nil),
"空口令头": func() *http.Request {
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("X-Auth-Token", "")
return r
}(),
"编码了但不是口令": func() *http.Request {
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("X-Auth-Token", "%E5%8F%A3%E4%BB%A4")
return r
}(),
}
for name, r := range cases {
if checkAuthToken(r) {
t.Fatalf("%s:不该通过", name)
}
}
// 口令没配时一律拒绝,不能因为「都是空」就放行
withAuthToken(t, "")
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("X-Auth-Token", "")
if checkAuthToken(r) {
t.Fatal("口令为空时不该通过")
}
}
func TestAuthTokenCandidatesOrder(t *testing.T) {
// 三种写法同时出现时按 Bearer → X-Auth-Token → ?token= 的顺序给候选,
// 顺序本身不影响结果,但别写出重复项(重复比较纯属浪费)
r := httptest.NewRequest(http.MethodGet, "/api/status?token=qqq", nil)
r.Header.Set("Authorization", "Bearer aaa")
r.Header.Set("X-Auth-Token", "bbb")
got := authTokenCandidates(r)
want := []string{"aaa", "bbb", "qqq"}
if len(got) != len(want) {
t.Fatalf("候选数量不对:%q", got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("候选顺序不对:%q,想要 %q", got, want)
}
}
// Authorization 不是 Bearer 前缀时不算数
r = httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("Authorization", "Basic zzz")
r.Header.Set("X-Auth-Token", "bbb")
got = authTokenCandidates(r)
if len(got) != 1 || got[0] != "bbb" {
t.Fatalf("非 Bearer 的 Authorization 不该当候选:%q", got)
}
}
// ==================== 口令错误记录的容量 ====================
// IP 是外部给的:只靠「过期清理」挡不住换着地址撞口令的情况,
// 必须要有硬上限。这些用例锁住这一点。
// 换着来源持续撞口令,表必须被压在上限以内,不能一直涨
func TestAuthFailTableHasHardCap(t *testing.T) {
saved, savedSweep := authFail.m, authFail.sinceSweep
t.Cleanup(func() {
authFail.Lock()
authFail.m, authFail.sinceSweep = saved, savedSweep
authFail.Unlock()
})
authFail.Lock()
authFail.m, authFail.sinceSweep = nil, 0
authFail.Unlock()
// 每个都是新来源、且都是「刚刚」失败过 —— 过期清理一条都删不掉的情况
for i := 0; i < authFailMaxEntries*2; i++ {
authFailDelay(fmt.Sprintf("198.51.100.%d", i))
}
got := 0
authFail.Lock()
got = len(authFail.m)
authFail.Unlock()
if got > authFailMaxEntries {
t.Errorf("记录数应被压在上限 %d 以内,实际 %d(换 IP 撞口令会把内存写满)",
authFailMaxEntries, got)
}
t.Logf(" %d 个新来源撞完,表内留下 %d 条(上限 %d)", authFailMaxEntries*2, got, authFailMaxEntries)
}
// 超过过期时间的记录要被扫掉,不是在表里一直躺着
func TestAuthFailExpiredEntriesGetSwept(t *testing.T) {
saved, savedSweep := authFail.m, authFail.sinceSweep
t.Cleanup(func() {
authFail.Lock()
authFail.m, authFail.sinceSweep = saved, savedSweep
authFail.Unlock()
})
authFail.Lock()
authFail.m, authFail.sinceSweep = nil, 0
authFail.Unlock()
for i := 0; i < authFailSweepEvery+5; i++ {
authFailDelay(fmt.Sprintf("203.0.113.%d", i))
}
authFail.Lock()
before := len(authFail.m)
// 把记录的最后时间推到过期之前
for k, v := range authFail.m {
authFail.m[k] = authFailEntry{count: v.count, last: time.Now().Add(-2 * authFailExpire)}
}
authFail.Unlock()
sweepAuthFail(time.Now())
authFail.Lock()
after := len(authFail.m)
authFail.Unlock()
if after != 0 {
t.Errorf("已经全部过期的记录应被扫干净,扫之前 %d 条,扫之后 %d 条", before, after)
}
}
// 扫表必须是自动发生的:不要求调用方记着手动清理,
// 混在里面的过期项应该在若干次记录之后自己消失
func TestAuthFailSweepsPeriodically(t *testing.T) {
saved, savedSweep := authFail.m, authFail.sinceSweep
t.Cleanup(func() {
authFail.Lock()
authFail.m, authFail.sinceSweep = saved, savedSweep
authFail.Unlock()
})
authFail.Lock()
authFail.m, authFail.sinceSweep = nil, 0
authFail.Unlock()
// 直接往里塞一条早就过期了的记录(绕过 authFailDelay,模拟「以前留下的」)
authFail.Lock()
if authFail.m == nil {
authFail.m = make(map[string]authFailEntry)
}
authFail.m["203.0.113.99"] = authFailEntry{count: 1, last: time.Now().Add(-2 * authFailExpire)}
authFail.Unlock()
// 之后只要还在有人失败,过一阵子它就应该被清掉
for i := 0; i < authFailSweepEvery+1; i++ {
authFailDelay(fmt.Sprintf("198.51.100.%d", i))
}
authFail.Lock()
_, still := authFail.m["203.0.113.99"]
authFail.Unlock()
if still {
t.Errorf("记了 %d 次之后,过期记录应当已经被自动扫掉", authFailSweepEvery+1)
}
}
// 同一个来源的计数不能无限涨(临界点是延迟早已封顶,再涨只是让数字溢出)
func TestAuthFailCountCapped(t *testing.T) {
saved, savedSweep := authFail.m, authFail.sinceSweep
t.Cleanup(func() {
authFail.Lock()
authFail.m, authFail.sinceSweep = saved, savedSweep
authFail.Unlock()
})
authFail.Lock()
authFail.m, authFail.sinceSweep = nil, 0
authFail.Unlock()
for i := 0; i < authFailMaxCount+50; i++ {
authFailDelay("192.0.2.9")
}
authFail.Lock()
c := authFail.m["192.0.2.9"].count
authFail.Unlock()
if c > authFailMaxCount {
t.Errorf("同一来源的次数应封顶在 %d,实际 %d", authFailMaxCount, c)
}
if d := authFailDelay("192.0.2.9"); d != authMaxDelay {
t.Errorf("打满之后延迟应封顶在 %v,实际 %v", authMaxDelay, d)
}
}
// 客户端断开时延迟等待必须立刻返回,不能傻等满 2 秒
func TestAwaitAuthDelayStopsOnCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(80 * time.Millisecond)
cancel()
}()
start := time.Now()
waited := awaitAuthDelay(ctx, 2*time.Second)
used := time.Since(start)
if waited {
t.Error("上游取消时 awaitAuthDelay 应返回 false")
}
if used > time.Second {
t.Errorf("上游取消后应立刻返回,实际等了 %v", used)
}
// 正常等待不受影响
if !awaitAuthDelay(context.Background(), 10*time.Millisecond) {
t.Error("没被取消时应当正常等完再返回 true")
}
}
var (
webInputIDRe = regexp.MustCompile(`<(?:input|select|textarea)[^>]*\bid="([A-Za-z0-9_]+)"`)
webSettingFieldsRe = regexp.MustCompile(`var settingFields = \[([^\]]*)\]`)
webNumberFieldsRe = regexp.MustCompile(`var numberFields = \{([^}]*)\}`)
webQuotedRe = regexp.MustCompile(`"([A-Za-z0-9_]+)"`)
)
// 设置页里每个能改的输入框都必须在 settingFields 里登记:漏登记的后果是「改了、按了保存,
// 值却没被提交」,3 秒后刷新又被刷回原值 —— 页面看起来完全正常,也没有任何报错。
// 反过来,settingFields 里写的 id 如果页面上不存在,refresh 时就静默填不进去。
//
// settingFields / numberFields 都是页面里的 JS 变量(整页在 webuiHTML 这个原始串里),
// 所以这里直接把它们从串里抠出来比对,不另维护一份名单
func TestWebuiSettingFieldsMatchInputs(t *testing.T) {
ids := map[string]bool{}
for _, m := range webInputIDRe.FindAllStringSubmatch(webuiHTML, -1) {
ids[m[1]] = true
}
m := webSettingFieldsRe.FindStringSubmatch(webuiHTML)
if m == nil {
t.Fatal("没能在页面里找到 settingFields 列表")
}
settings := map[string]bool{}
for _, q := range webQuotedRe.FindAllStringSubmatch(m[1], -1) {
settings[q[1]] = true
if !ids[q[1]] {
t.Errorf("settingFields 里的 %q 在页面上找不到对应的输入框", q[1])
}
}
// 这些输入框刻意不进设置:要么是手动合并卡片自己的参数(随合并请求提交),
// 要么是路径选择弹窗 / 口令框的内部状态
notSettings := map[string]string{
"authTokenNew": "重新生成口令,走单独的接口",
"authTokenView": "只读展示当前口令",
"tokenInput": "页面解锁框,不是设置项",
"newuid": "添加主播,走主播接口",
"pickHidden": "路径选择弹窗的内部状态",
"pickPath": "路径选择弹窗的内部状态",
"mrgVideoPick": "手动合并:视频列表,随合并请求提交",
"mrgVideos": "手动合并:视频路径多行框,随合并请求提交",
"mrgDanmu": "手动合并:弹幕文件,随合并请求提交",
"mrgOffset": "手动合并:偏移秒数,随合并请求提交",
"mrgOut": "手动合并:成片名字,随合并请求提交",
}
for id := range ids {
if settings[id] {
continue
}
if _, ok := notSettings[id]; ok {
continue
}
t.Errorf("页面上的 %q 既不在 settingFields 里,也没说明为什么不算设置项:改了它不会被保存", id)
}
// numberFields 里的 id 必须都是设置项,否则 parseInt 的结果没人用
n := webNumberFieldsRe.FindStringSubmatch(webuiHTML)
if n == nil {
t.Fatal("没能在页面里找到 numberFields 列表")
}
for _, m := range regexp.MustCompile(`([A-Za-z0-9_]+)\s*:`).FindAllStringSubmatch(n[1], -1) {
if !settings[m[1]] {
t.Errorf("numberFields 里的 %q 不是设置项", m[1])
}
}
}
// 保存设置必须把新增字段真的落到 config 上:漏一个赋值就是「界面显示已保存、实际没生效」,
// 3 秒后刷新还被刷回原值,页面上完全看不出问题
func TestWebConfigHandlerSavesMergeFpsAndNotifyMergeFail(t *testing.T) {
dir := setupMergeTest(t)
savedConfig := config
savedLocation := configFileLocation
oldDir := configDir
if configDir == nil {
configDir = new(string)
}
defer func() {
config = savedConfig
configFileLocation = savedLocation
configDir = oldDir
}()
*configDir = dir
configFileLocation = filepath.Join(dir, configFile)
post := func(body map[string]any) string {
t.Helper()
data, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(data))
rec := httptest.NewRecorder()
webConfigHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("保存设置被拒(%d):%s", rec.Code, rec.Body.String())
}
return rec.Body.String()
}
base := func() map[string]any {
return map[string]any{
"directory": dir,
"ffmpegPath": testFFmpeg(t),
"recordSubdir": defaultRecordSubdir,
"mergeSubdir": defaultMergeSubdir,
}
}
config.MergeFps = 0
config.NotifyMergeFail = true
b := base()
b["mergeFps"] = 60
b["notifyMergeFail"] = false
post(b)
if config.MergeFps != 60 {
t.Errorf("mergeFps 没被保存下来:%d", config.MergeFps)
}
if config.NotifyMergeFail {
t.Error("notifyMergeFail=false 没被保存下来")
}
// 超范围(负数或大得离谱)必须挡在保存之前
for _, bad := range []int{-1, maxMergeFps + 1} {
config.MergeFps = 7
b = base()
b["mergeFps"] = bad
data, _ := json.Marshal(b)
req := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(data))
rec := httptest.NewRecorder()
webConfigHandler(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("mergeFps=%d 应当被拒(400),实际 %d", bad, rec.Code)
}
if config.MergeFps != 7 {
t.Errorf("mergeFps=%d 被拒后不该改到配置里,实际 %d", bad, config.MergeFps)
}
}
}