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

264 lines
7.9 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 (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// 推送的事件名,外部(如 NoneBot 机器人)按这个字段区分事件
const (
notifyEventLiveOn = "liveOn"
notifyEventLiveOff = "liveOff"
notifyEventRecordStart = "recordStart"
notifyEventMergeDone = "mergeDone"
notifyEventMergeFail = "mergeFail"
)
// 推送到 webhook 的请求体
type notifyPayload struct {
Event string `json:"event"` // 事件名:liveOn、liveOff、recordStart、mergeDone、mergeFail
UID int `json:"uid"` // 主播 uid
Name string `json:"name"` // 主播名字
Title string `json:"title"` // 直播标题(下播和合并完成时可能为空)
URL string `json:"url"` // 直播间地址
File string `json:"file"` // 相关文件路径(录制开始时的视频文件、合并完成时的成片)
Group string `json:"group"` // web UI 里配置的通知群号,机器人可以直接用它决定发到哪个群
Text string `json:"text"` // 可以直接发出去的文本,机器人拿到后原样发送即可
Time string `json:"time"` // 事件发生的时刻
}
// 推送用的 http 客户端,超时短一点,避免推送把任务拖住
var notifyClient = &http.Client{Timeout: 10 * time.Second}
// 已经推送过开播通知的 liveID,避免重连或改设置时重复推送
var notifiedLive struct {
sync.Mutex
ids map[string]struct{}
}
// 首次见到这个 liveID 时返回 true
func notifiedLiveOnce(liveID string) bool {
if liveID == "" {
return false
}
notifiedLive.Lock()
defer notifiedLive.Unlock()
if notifiedLive.ids == nil {
notifiedLive.ids = make(map[string]struct{})
}
if _, ok := notifiedLive.ids[liveID]; ok {
return false
}
notifiedLive.ids[liveID] = struct{}{}
// 长时间运行后简单地清一次,避免无限增长
if len(notifiedLive.ids) > 4096 {
notifiedLive.ids = map[string]struct{}{liveID: {}}
}
return true
}
// 推送设置的一份快照,避免在推送过程中读到正在被修改的配置
type notifySetting struct {
// QQ 机器人(webhook)渠道
qqEnabled bool
url string
token string
group string
// MeoW 渠道
meowEnabled bool
meowNick string
meowMsgType string
meowHTML int
meowImg string
// 推哪些事件,两个渠道共用
liveOn bool
liveOff bool
recordStart bool
mergeDone bool
mergeFail bool
}
func currentNotifySetting() notifySetting {
streamers.RLock()
defer streamers.RUnlock()
return notifySetting{
qqEnabled: config.NotifyEnabled,
url: strings.TrimSpace(config.NotifyURL),
token: strings.TrimSpace(config.NotifyToken),
group: strings.TrimSpace(config.NotifyGroup),
meowEnabled: config.MeowEnabled,
meowNick: strings.TrimSpace(config.MeowNickname),
meowMsgType: normalizeMeowMsgType(config.MeowMsgType),
meowHTML: clampMeowHTMLHeight(config.MeowHTMLHeight),
meowImg: strings.TrimSpace(config.MeowImgURL),
liveOn: config.NotifyLiveOn,
liveOff: config.NotifyLiveOff,
recordStart: config.NotifyRecordStart,
mergeDone: config.NotifyMergeDone,
mergeFail: config.NotifyMergeFail,
}
}
// 该事件是否开启了推送
func (n notifySetting) enabled(event string) bool {
switch event {
case notifyEventLiveOn:
return n.liveOn
case notifyEventLiveOff:
return n.liveOff
case notifyEventRecordStart:
return n.recordStart
case notifyEventMergeDone:
return n.mergeDone
case notifyEventMergeFail:
return n.mergeFail
}
return false
}
// QQ 机器人渠道是否具备推送条件:渠道开关打开且填了地址
func (n notifySetting) qqReady() bool {
return n.qqEnabled && n.url != ""
}
// MeoW 渠道是否具备推送条件:渠道开关打开、昵称可用、且这个昵称没被 MeoW 判过不存在
func (n notifySetting) meowBlockedReason() string {
return meowNickProblem(n.meowNick)
}
func (n notifySetting) meowReady() bool {
return n.meowEnabled && n.meowBlockedReason() == ""
}
// 页面上要显示的 MeoW 提示,没问题时返回空字符串。调用方需要持有 streamers 锁
func meowStatusProblem() string {
if !config.MeowEnabled {
return ""
}
return meowNickProblem(config.MeowNickname)
}
// 推送一个事件。没开启对应事件、或两个渠道都没启用时什么都不做。
// 推送在后台 goroutine 里进行,失败只打警告,不会影响录制
func sendNotify(event string, uid int, name, title, url, file, text string) {
n := currentNotifySetting()
if !n.enabled(event) {
return
}
qq, meow := n.qqReady(), n.meowReady()
if !qq && !meow {
// MeoW 开着却推不了时说清楚原因,不然用户会以为推送整个坏了
if n.meowEnabled {
lPrintWarnf("MeoW 渠道已启用但推不出去(%s),本次%s通知没有推送", n.meowBlockedReason(), event)
}
return
}
p := notifyPayload{
Event: event,
UID: uid,
Name: name,
Title: title,
URL: url,
File: file,
Group: n.group,
Text: text,
Time: getLogTime(),
}
// 推送地址和口令可能在推送过程中被改掉,这里带进闭包,保证用的是同一次配置
if qq {
addr, token := n.url, n.token
safeGo("推送"+event+"通知到QQ机器人", func() {
if _, err := postNotify(addr, token, p); err != nil {
lPrintWarnf("推送%s通知到 QQ 机器人失败:%v", event, err)
return
}
lPrintf("已推送%s通知到 QQ 机器人:%s", event, p.Text)
})
}
if meow {
nick, img, msgType, msgHeight := n.meowNick, n.meowImg, n.meowMsgType, n.meowHTML
// 合并完成时带的是本地成片路径,手机上打不开,所以只认 http/https 的直播间地址
jump := p.URL
if !isHTTPURL(jump) {
jump = ""
}
eventTitle := notifyEventTitle(event)
content := p.Text
if msgType == meowMsgTypeHTML {
content = meowHTMLText(p.Text)
}
safeGo("推送"+event+"通知到MeoW", func() {
if err := pushMeow(nick, eventTitle, content, jump, img, msgType, msgHeight); err != nil {
lPrintWarnf("推送%s通知到 MeoW 失败:%v", event, err)
return
}
lPrintf("已推送%s通知到 MeoW(%s):%s", event, nick, p.Text)
})
}
}
// 合并失败时推送。nil、「弹幕文件里没有弹幕」和被手动停止都不算失败,不会推送。
// fileName 用于正文里标出是哪一场,拿不到就留空
func notifyMergeFailure(uid int, fileName string, err error) {
if err == nil || err == errNoDanmu || err == errJobStopped {
return
}
text := "合并失败"
if uid > 0 {
text += "(" + longID(uid) + " 的录播和弹幕)"
} else if fileName != "" {
text += "(" + fileName + ")"
}
text += "\n原因:" + err.Error()
sendNotify(notifyEventMergeFail, uid, nameForLog(uid), "", "", fileName, text)
}
// 把事件 POST 到 webhook,同时返回 HTTP 状态码(失败时也会带上,方便测试接口显示细节)
func postNotify(addr, token string, p notifyPayload) (int, error) {
data, err := json.Marshal(p)
if err != nil {
return 0, err
}
req, err := http.NewRequest(http.MethodPost, addr, bytes.NewReader(data))
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Auth-Token", token)
}
resp, err := notifyClient.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return resp.StatusCode, fmt.Errorf("HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return resp.StatusCode, nil
}
// 检查设置里的推送地址,返回错误信息,空字符串表示通过
func checkNotifyURL(addr string) string {
addr = strings.TrimSpace(addr)
if addr == "" {
return ""
}
if !strings.HasPrefix(addr, "http://") && !strings.HasPrefix(addr, "https://") {
return "推送地址必须以 http:// 或 https:// 开头:" + addr
}
return ""
}