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。
298 lines
8.4 KiB
Go
298 lines
8.4 KiB
Go
// MeoW 消息推送,接口文档:https://www.chuckfang.com/MeoW/api_doc.html
|
||
// MeoW 的 HTTP 状态一律是 200,真正的结果在响应 json 的 status 字段里,所以要读 body 才能判断成败
|
||
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"html"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
meowMsgTypeText = "text"
|
||
meowMsgTypeHTML = "html"
|
||
|
||
// HTML 消息的显示高度,文档默认 200,limits 是防止填出个渲染不出来的值
|
||
defaultMeowHTMLHeight = 200
|
||
minMeowHTMLHeight = 40
|
||
maxMeowHTMLHeight = 2000
|
||
)
|
||
|
||
// 接口地址。文档也提供 http,但推送内容会带直播标题、昵称这些信息,统一走 https。
|
||
// 变量而不是常量,测试里可以换成自己的服务器
|
||
var meowAPIBase = "https://api.chuckfang.com/"
|
||
|
||
// MeoW 对「昵称不存在」很敏感:官方提示一天内累计 3 次输错昵称就会封 IP。
|
||
// 所以一旦确认某个昵称不可用就不再向它推送,等昵称被改掉(或重启程序)才恢复,
|
||
// 免得开播/下播/合并完成这些事件反复触发把用户 IP 封了
|
||
var meowBadNick struct {
|
||
sync.Mutex
|
||
nick string // 已知不可用的昵称,空表示没有
|
||
reason string
|
||
}
|
||
|
||
// 记下一个不可用的昵称
|
||
func markMeowBadNick(nick, reason string) {
|
||
nick = strings.TrimSpace(nick)
|
||
if nick == "" {
|
||
return
|
||
}
|
||
meowBadNick.Lock()
|
||
defer meowBadNick.Unlock()
|
||
if meowBadNick.nick == nick {
|
||
return
|
||
}
|
||
meowBadNick.nick, meowBadNick.reason = nick, reason
|
||
}
|
||
|
||
// 清掉「昵称不可用」的记录。生产里靠改昵称或重启程序恢复,这里主要给测试用
|
||
func resetMeowBadNick() {
|
||
meowBadNick.Lock()
|
||
defer meowBadNick.Unlock()
|
||
meowBadNick.nick, meowBadNick.reason = "", ""
|
||
}
|
||
|
||
// 昵称是否已被判定不可用,返回原因(空字符串表示可以用)
|
||
func meowBadNickReason(nick string) string {
|
||
nick = strings.TrimSpace(nick)
|
||
if nick == "" {
|
||
return ""
|
||
}
|
||
meowBadNick.Lock()
|
||
defer meowBadNick.Unlock()
|
||
if meowBadNick.nick == nick {
|
||
return meowBadNick.reason
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// MeoW 昵称为什么推不出去(没填、格式不对,或已被 MeoW 判过不存在),空字符串表示没问题
|
||
func meowNickProblem(nick string) string {
|
||
if strings.TrimSpace(nick) == "" {
|
||
return "还没有填写昵称"
|
||
}
|
||
if msg := checkMeowNickname(nick, false); msg != "" {
|
||
return msg
|
||
}
|
||
return meowBadNickReason(nick)
|
||
}
|
||
|
||
// MeoW 的响应体。文档「响应格式」那节写的是 msg,示例里写的是 message,两个都收
|
||
type meowResponse struct {
|
||
Status int `json:"status"`
|
||
Msg string `json:"msg"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
func (r meowResponse) detail() string {
|
||
if s := strings.TrimSpace(r.Message); s != "" {
|
||
return s
|
||
}
|
||
return strings.TrimSpace(r.Msg)
|
||
}
|
||
|
||
// 推送用的客户端。MeoW 是公网服务,超时给得比 webhook 宽松一点
|
||
var meowClient = &http.Client{Timeout: 15 * time.Second}
|
||
|
||
// MeoW 昵称是否可用。allowEmpty 为 true 时允许留空(表示还没配,不影响程序启动)
|
||
func checkMeowNickname(nick string, allowEmpty bool) string {
|
||
nick = strings.TrimSpace(nick)
|
||
if nick == "" {
|
||
if allowEmpty {
|
||
return ""
|
||
}
|
||
return "还没填写 MeoW 昵称"
|
||
}
|
||
// 昵称是推送地址里的路径参数,文档明确不允许斜杠,问号井号这类也会把地址截断
|
||
if strings.ContainsAny(nick, `/\?#`) {
|
||
return "MeoW 昵称不能包含 / \\ ? # 这些字符:" + nick
|
||
}
|
||
if len([]rune(nick)) > 64 {
|
||
return "MeoW 昵称太长(超过 64 个字符):" + nick
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// MeoW 的消息类型,只认 text 和 html,其它和空值都回退到 text
|
||
func normalizeMeowMsgType(t string) string {
|
||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||
case meowMsgTypeHTML:
|
||
return meowMsgTypeHTML
|
||
case "", meowMsgTypeText:
|
||
return meowMsgTypeText
|
||
}
|
||
lPrintWarnf("不支持的 MeoW 消息类型 %q,改用 %s(可选:text、html)", t, meowMsgTypeText)
|
||
return meowMsgTypeText
|
||
}
|
||
|
||
// HTML 消息的显示高度,超出范围就夹到边界,非正数用默认值
|
||
func clampMeowHTMLHeight(h int) int {
|
||
if h <= 0 {
|
||
return defaultMeowHTMLHeight
|
||
}
|
||
if h < minMeowHTMLHeight {
|
||
return minMeowHTMLHeight
|
||
}
|
||
if h > maxMeowHTMLHeight {
|
||
return maxMeowHTMLHeight
|
||
}
|
||
return h
|
||
}
|
||
|
||
// 拼推送地址。msgType、htmlHeight 是请求参数,不能放进 json body 里
|
||
func meowURL(nick, msgType string, htmlHeight int) string {
|
||
q := url.Values{}
|
||
q.Set("msgType", msgType)
|
||
if msgType == meowMsgTypeHTML {
|
||
q.Set("htmlHeight", strconv.Itoa(clampMeowHTMLHeight(htmlHeight)))
|
||
}
|
||
return meowAPIBase + url.PathEscape(strings.TrimSpace(nick)) + "?" + q.Encode()
|
||
}
|
||
|
||
// MeoW 的业务状态码说明
|
||
func meowStatusText(status int) string {
|
||
switch status {
|
||
case 200:
|
||
return "推送成功"
|
||
case 400:
|
||
return "标题或消息参数错误"
|
||
case 403:
|
||
return "内容被 MeoW 的策略拦下,不允许发送"
|
||
case 404:
|
||
return "昵称未注册"
|
||
case 429:
|
||
return "触发频率限制(非会员为 3 秒 1 条、每分钟 15 条、每小时 60 条)"
|
||
case 500:
|
||
return "MeoW 服务器错误"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// 截断过长的响应内容,避免把整个 body 打进日志或页面
|
||
func briefBody(raw []byte) string {
|
||
s := strings.TrimSpace(string(raw))
|
||
if len(s) > 300 {
|
||
s = s[:300] + "…"
|
||
}
|
||
return s
|
||
}
|
||
|
||
// 推送一条消息到 MeoW。昵称是接收者,title 留空时 MeoW 会用它自己的默认标题
|
||
func pushMeow(nick, title, msg, jumpURL, imgURL, msgType string, htmlHeight int) error {
|
||
if err := checkMeowNickname(nick, false); err != "" {
|
||
return fmt.Errorf("%s", err)
|
||
}
|
||
if strings.TrimSpace(msg) == "" {
|
||
return fmt.Errorf("推送内容为空")
|
||
}
|
||
msgType = normalizeMeowMsgType(msgType)
|
||
|
||
// 空字段直接不放进去:body 里的 title 优先级高于路径参数,
|
||
// 传空字符串有可能把 MeoW 的默认标题顶掉
|
||
body := map[string]string{"msg": msg}
|
||
if s := strings.TrimSpace(title); s != "" {
|
||
body["title"] = s
|
||
}
|
||
if s := strings.TrimSpace(jumpURL); s != "" {
|
||
body["url"] = s
|
||
}
|
||
if s := strings.TrimSpace(imgURL); s != "" {
|
||
body["imgUrl"] = s
|
||
}
|
||
data, err := json.Marshal(body)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
req, err := http.NewRequest(http.MethodPost, meowURL(nick, msgType, htmlHeight), bytes.NewReader(data))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||
|
||
resp, err := meowClient.Do(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||
|
||
var r meowResponse
|
||
if err := json.Unmarshal(raw, &r); err != nil {
|
||
return fmt.Errorf("MeoW 返回的不是合法 json(HTTP %d):%s", resp.StatusCode, briefBody(raw))
|
||
}
|
||
if r.Status != 200 {
|
||
// 昵称不存在是最危险的一种失败:MeoW 会对错误昵称记数并封 IP,
|
||
// 所以记下来,后续不再向这个昵称重试
|
||
if r.Status == 404 {
|
||
markMeowBadNick(nick, meowStatusText(404))
|
||
}
|
||
if text := meowStatusText(r.Status); text != "" {
|
||
return fmt.Errorf("MeoW 返回 status %d(%s)%s", r.Status, text, joinDetail(r.detail()))
|
||
}
|
||
return fmt.Errorf("MeoW 返回 status %d%s", r.Status, joinDetail(r.detail()))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func joinDetail(detail string) string {
|
||
if detail == "" {
|
||
return ""
|
||
}
|
||
return ":" + detail
|
||
}
|
||
|
||
// 各事件在通知里的短标题,MeoW 的通知栏和 webhook 的正文都用得到
|
||
func notifyEventTitle(event string) string {
|
||
switch event {
|
||
case notifyEventLiveOn:
|
||
return "开播提醒"
|
||
case notifyEventLiveOff:
|
||
return "下播提醒"
|
||
case notifyEventRecordStart:
|
||
return "开始录制"
|
||
case notifyEventMergeDone:
|
||
return "合并完成"
|
||
case notifyEventMergeFail:
|
||
return "合并失败"
|
||
}
|
||
return "AcFun 直播录制"
|
||
}
|
||
|
||
// 看起来像 http/https 链接
|
||
func isHTTPURL(s string) bool {
|
||
s = strings.ToLower(strings.TrimSpace(s))
|
||
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
|
||
}
|
||
|
||
// 把事件正文渲染成 MeoW 的 HTML 消息:第一行加粗,链接单独成行时做成可点的,其余每行一个段落
|
||
func meowHTMLText(text string) string {
|
||
var b strings.Builder
|
||
first := true
|
||
for _, line := range strings.Split(strings.TrimSpace(text), "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if line == "" {
|
||
continue
|
||
}
|
||
esc := html.EscapeString(line)
|
||
switch {
|
||
case first:
|
||
b.WriteString("<p><b>" + esc + "</b></p>")
|
||
first = false
|
||
case isHTTPURL(line):
|
||
b.WriteString(`<p><a href="` + esc + `">` + esc + `</a></p>`)
|
||
default:
|
||
b.WriteString("<p>" + esc + "</p>")
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|