修分块渲染的弹幕轨迹:跨块续画 + 文字头像同源,并补失败推送与输出帧率设置

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。
This commit is contained in:
tamakyi
2026-09-26 16:34:47 +08:00
parent 3eb4aa3102
commit 6f3c8096b4
16 changed files with 1041 additions and 124 deletions
+144
View File
@@ -2,11 +2,15 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"regexp"
"testing"
"time"
)
@@ -303,3 +307,143 @@ func TestAwaitAuthDelayStopsOnCancel(t *testing.T) {
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)
}
}
}