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。
264 lines
7.9 KiB
Go
264 lines
7.9 KiB
Go
// 开播、下播、开始录制、合并完成等事件的推送
|
||
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 ""
|
||
}
|