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

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
+24 -8
View File
@@ -381,6 +381,7 @@ const webuiHTML = `<!DOCTYPE html>
</div>
</div>
<div class="f"><label>合成码率(Kbps,0 = 源片码率 + 500)</label><input type="text" id="mergeBitrate" inputmode="numeric" placeholder="例如 1500,填 0 自动"></div>
<div class="f"><label>输出帧率(0 = 跟随源片)</label><input type="text" id="mergeFps" inputmode="numeric" placeholder="0 跟随源片;填例如 60 让低帧率录播里的弹幕更顺滑"></div>
<div class="f"><label>弹幕字号(像素,0 = 自动按画面分辨率)</label><input type="text" id="danmuFontSize" inputmode="numeric" placeholder="0 自动,或填例如 42;头像随字号一起缩放"></div>
</div>
<div class="hint">文本框改完按回车或点右上角「保存设置」生效,不做这一步的话 3 秒后会被刷回原值。</div>
@@ -436,8 +437,9 @@ const webuiHTML = `<!DOCTYPE html>
<label class="sw"><input type="checkbox" id="notifyLiveOff"><span class="sw-i"></span>下播</label>
<label class="sw"><input type="checkbox" id="notifyRecordStart"><span class="sw-i"></span>开始录制</label>
<label class="sw"><input type="checkbox" id="notifyMergeDone"><span class="sw-i"></span>合并完成</label>
<label class="sw"><input type="checkbox" id="notifyMergeFail"><span class="sw-i"></span>合并失败</label>
</div>
<div class="hint">推送失败只记警告,不影响录制。</div>
<div class="hint">推送失败只记警告,不影响录制。「合并失败」不含「文件里没有弹幕」和你自己点的手动停止。</div>
</div>
<div class="card">
<div class="card-h"><h2>渠道一 · QQ 机器人</h2><span class="sub">自建机器人,走 HTTP POST JSON</span></div>
@@ -551,8 +553,8 @@ var token = localStorage.getItem("acfunlive_token") || ""; // 管理口令,存
var authed = false; // 是否已通过口令校验,未通过时停止轮询
var tokenLoaded = false;
// 设置表单里的字段,元素 id 与 /api/config 的字段名一致
var settingFields = ["directory","autoMerge","mergeDelVideo","mergeDelDanmu","mergeOutputDir","mergeBitrate","danmuFontSize","defaultQualityPref","defaultBitrate","recordSubdir","mergeSubdir","ffmpegPath","recordFormat","notifyEnabled","notifyUrl","notifyToken","notifyGroup","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","meowEnabled","meowNickname","meowMsgType","meowHtmlHeight","meowImgUrl","webExternal"];
var numberFields = {mergeBitrate:1, danmuFontSize:1, defaultBitrate:1, meowHtmlHeight:1};
var settingFields = ["directory","autoMerge","mergeDelVideo","mergeDelDanmu","mergeOutputDir","mergeBitrate","mergeFps","danmuFontSize","defaultQualityPref","defaultBitrate","recordSubdir","mergeSubdir","ffmpegPath","recordFormat","notifyEnabled","notifyUrl","notifyToken","notifyGroup","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","notifyMergeFail","meowEnabled","meowNickname","meowMsgType","meowHtmlHeight","meowImgUrl","webExternal"];
var numberFields = {mergeBitrate:1, mergeFps:1, danmuFontSize:1, defaultBitrate:1, meowHtmlHeight:1};
// 用户改过、还没提交的字段。定时刷新会拿服务端的值填表单,不记下这些字段的话,
// 刚填好的数字 3 秒后会被刷回旧值,看起来就像「改了又自己恢复」
var dirty = {};
@@ -716,6 +718,7 @@ async function refresh(){
setVal("directory", st.directory||"");
setVal("mergeOutputDir", st.mergeOutputDir||"");
setVal("mergeBitrate", st.mergeBitrate||0);
setVal("mergeFps", st.mergeFps||0);
setVal("danmuFontSize", st.danmuFontSize||0);
setVal("defaultQualityPref", st.defaultQualityPref||"second");
setVal("defaultBitrate", st.defaultBitrate||0);
@@ -732,6 +735,7 @@ async function refresh(){
setChk("notifyLiveOff", st.notifyLiveOff);
setChk("notifyRecordStart", st.notifyRecordStart);
setChk("notifyMergeDone", st.notifyMergeDone);
setChk("notifyMergeFail", st.notifyMergeFail);
setChk("meowEnabled", st.meowEnabled);
setVal("meowNickname", st.meowNickname||"");
setVal("meowMsgType", st.meowMsgType||"text");
@@ -885,7 +889,7 @@ async function saveSettings(b, autoSave){
if(b) b.disabled = false;
}
// 勾选类设置修改后立即保存,避免被定时刷新还原
["autoMerge","mergeDelVideo","mergeDelDanmu","webExternal","notifyEnabled","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","meowEnabled"].forEach(function(id){
["autoMerge","mergeDelVideo","mergeDelDanmu","webExternal","notifyEnabled","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","notifyMergeFail","meowEnabled"].forEach(function(id){
document.getElementById(id).addEventListener("change", function(){ dropDirty(id); saveSettings(null, true); });
});
// 下拉框改完也立即保存
@@ -1705,6 +1709,7 @@ type webStatus struct {
MergeDelDanmu bool `json:"mergeDelDanmu"`
MergeOutputDir string `json:"mergeOutputDir"`
MergeBitrate int `json:"mergeBitrate"`
MergeFps int `json:"mergeFps"`
DanmuFontSize int `json:"danmuFontSize"`
DefaultQualityPref string `json:"defaultQualityPref"`
DefaultBitrate int `json:"defaultBitrate"`
@@ -1721,6 +1726,7 @@ type webStatus struct {
NotifyLiveOff bool `json:"notifyLiveOff"`
NotifyRecordStart bool `json:"notifyRecordStart"`
NotifyMergeDone bool `json:"notifyMergeDone"`
NotifyMergeFail bool `json:"notifyMergeFail"`
MeowEnabled bool `json:"meowEnabled"`
MeowNickname string `json:"meowNickname"`
MeowMsgType string `json:"meowMsgType"`
@@ -1767,6 +1773,7 @@ func statusHandler(w http.ResponseWriter, r *http.Request) {
MergeDelDanmu: config.MergeDelDanmu,
MergeOutputDir: config.MergeOutputDir,
MergeBitrate: config.MergeBitrate,
MergeFps: config.MergeFps,
DanmuFontSize: config.DanmuFontSize,
WebExternal: config.WebExternal,
RecordSubdir: config.RecordSubdir,
@@ -1783,6 +1790,7 @@ func statusHandler(w http.ResponseWriter, r *http.Request) {
NotifyLiveOff: config.NotifyLiveOff,
NotifyRecordStart: config.NotifyRecordStart,
NotifyMergeDone: config.NotifyMergeDone,
NotifyMergeFail: config.NotifyMergeFail,
MeowEnabled: config.MeowEnabled,
MeowNickname: config.MeowNickname,
MeowMsgType: normalizeMeowMsgType(config.MeowMsgType),
@@ -1850,6 +1858,7 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
MergeDelDanmu bool `json:"mergeDelDanmu"`
MergeOutputDir string `json:"mergeOutputDir"`
MergeBitrate int `json:"mergeBitrate"`
MergeFps int `json:"mergeFps"`
DanmuFontSize int `json:"danmuFontSize"`
DefaultQualityPref string `json:"defaultQualityPref"`
DefaultBitrate int `json:"defaultBitrate"`
@@ -1866,6 +1875,7 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
NotifyLiveOff bool `json:"notifyLiveOff"`
NotifyRecordStart bool `json:"notifyRecordStart"`
NotifyMergeDone bool `json:"notifyMergeDone"`
NotifyMergeFail bool `json:"notifyMergeFail"`
MeowEnabled bool `json:"meowEnabled"`
MeowNickname string `json:"meowNickname"`
MeowMsgType string `json:"meowMsgType"`
@@ -1893,6 +1903,10 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "danmuFontSize 不能小于 0(0 表示按画面分辨率自动)", http.StatusBadRequest)
return
}
if req.MergeFps < 0 || req.MergeFps > maxMergeFps {
http.Error(w, fmt.Sprintf("mergeFps 只能在 0(跟随源片)到 %d 之间", maxMergeFps), http.StatusBadRequest)
return
}
if req.DefaultBitrate < 0 {
http.Error(w, "defaultBitrate 不能小于 0", http.StatusBadRequest)
return
@@ -1961,6 +1975,7 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
config.MergeDelDanmu = req.MergeDelDanmu
config.MergeOutputDir = req.MergeOutputDir
config.MergeBitrate = req.MergeBitrate
config.MergeFps = req.MergeFps
config.DanmuFontSize = req.DanmuFontSize
config.DefaultQualityPref = normalizeQualityPref(req.DefaultQualityPref)
config.DefaultBitrate = req.DefaultBitrate
@@ -1978,6 +1993,7 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
config.NotifyLiveOff = req.NotifyLiveOff
config.NotifyRecordStart = req.NotifyRecordStart
config.NotifyMergeDone = req.NotifyMergeDone
config.NotifyMergeFail = req.NotifyMergeFail
config.MeowEnabled = req.MeowEnabled
config.MeowNickname = meowNickname
config.MeowMsgType = meowMsgType
@@ -2001,14 +2017,14 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
lPrintln("外网访问设置已改变,重启 web UI 服务器")
restartWebUI()
}
lPrintf("通过 web UI 修改了设置:目录 %q、自动合并 %v、合并删视频 %v、合并删弹幕 %v、成片目录 %q、合并码率 %d、新主播默认档位 %s、外网访问 %v、录播子文件夹 %q、成片子文件夹 %q、ffmpeg 路径 %q、录播格式 %q",
lPrintf("通过 web UI 修改了设置:目录 %q、自动合并 %v、合并删视频 %v、合并删弹幕 %v、成片目录 %q、合并码率 %d、输出帧率 %d、新主播默认档位 %s、外网访问 %v、录播子文件夹 %q、成片子文件夹 %q、ffmpeg 路径 %q、录播格式 %q",
req.Directory, req.AutoMerge, req.MergeDelVideo, req.MergeDelDanmu, req.MergeOutputDir,
req.MergeBitrate, qualityLogText(req.DefaultQualityPref, req.DefaultBitrate), req.WebExternal,
req.MergeBitrate, req.MergeFps, qualityLogText(req.DefaultQualityPref, req.DefaultBitrate), req.WebExternal,
recordSubdir, mergeSubdir, config.FFmpegPath, recordFormat)
lPrintf("推送设置:QQ 机器人渠道 %v(地址 %q、群号 %q)、MeoW 渠道 %v(昵称 %q、类型 %s、高度 %d);事件 开播 %v、下播 %v、开始录制 %v、合并完成 %v",
lPrintf("推送设置:QQ 机器人渠道 %v(地址 %q、群号 %q)、MeoW 渠道 %v(昵称 %q、类型 %s、高度 %d);事件 开播 %v、下播 %v、开始录制 %v、合并完成 %v、合并失败 %v",
req.NotifyEnabled, notifyURL, config.NotifyGroup, req.MeowEnabled, meowNickname, meowMsgType,
clampMeowHTMLHeight(req.MeowHTMLHeight),
req.NotifyLiveOn, req.NotifyLiveOff, req.NotifyRecordStart, req.NotifyMergeDone)
req.NotifyLiveOn, req.NotifyLiveOff, req.NotifyRecordStart, req.NotifyMergeDone, req.NotifyMergeFail)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("true"))