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

298 lines
8.4 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.
// 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()
}