Files
acfunlive-remake/ass2jsonl.go
T
tamakyi a24425e16d 修第三批 P2:除零、停止/暂停的响应与状态一致性、临时文件、带 BOM 的 ASS
一、弹幕滚动时间不再整数除零
   write() 里拿 (PlayResX+length) 做除数,PlayResX 被配成 0 又收到一条空内容弹幕时
   分母正好是 0,直接 panic 终止整场录制。抽成 scrollMarks 并给分母为 0 的兜底
   (退回半个停留时长,至少让它正常显示出来)。

二、点停止不再要等整个块渲完
   jobAttach 原来只看 paused:停止请求若落在 jobGate 之后、ffmpeg 启动过程中,
   job.proc 还没登记,jobStop 拿不到进程可杀,用户得等整个块渲完(大块要几分钟)。
   现在 attach 时发现任务已请求停止就地把它杀掉,上层拿到 errJobStopped,
   卡片显示的是「已停止」而不是「失败」。

三、暂停没挂住就回滚状态
   jobPause 先把 job.paused 置 true 再调 suspendProcess,挂起失败时状态已经改了:
   界面显示「已暂停」而 ffmpeg 还在跑。失败时回滚(只在 proc 仍是同一个进程时回滚)。
   jobResume 同理:恢复不成放回「已暂停」,免得显示在跑却停着。

四、.ass 还原的临时 jsonl 用完就删
   还原结果写不进录播目录时会退到系统临时目录的固定名文件,原来从不清理,
   每失败一次留一份。新增 danmuSource.tempJsonl 标记 + dropTempDanmu 收尾删除;
   只删标记过的临时文件 —— 正常情况下那个路径是用户录播目录里的同名 jsonl,
   删了就是丢数据。

五、带 BOM 的 ASS 能整份解析
   带 UTF-8 BOM 时首行是 BOM+[Script Info],一个节都进不去,最后只报
   「可能不是弹幕文件」,跟真实原因完全对不上。解析时剥掉 BOM。

六、头像下载期间暂停/停止不用再干等
   单次超时 30 秒 × 3 次重试,最坏 96 秒才响应暂停/停止。单次超时收到 10 秒
   (头像只有几百 KB),并且每次重试前过闸门:暂停在这里等、停止直接放弃,
   不再把剩下的重试跑完;停止也不再打「头像不可用」的告警。

验证:
- gofmt -l 空;go vet ./... 空;go build 与 GOOS=linux go build 通过
- go test ./... 全绿(10.9s);go test -race ./... 全绿(14.5s)
- 新增 p2_fixes_test.go 8 个用例;本轮 6 项加上一批 6 项共 12 项反向验证全部通过
  (每项临时退回源码 → 对应测试确实失败 → 恢复)
- 51897 隔离实例上完整复测:e2e.py 37 项全过、smoke_extra.py 33 项全过
2026-09-26 10:07:30 +08:00

404 lines
11 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.
// 弹幕文件的读写与格式还原
// 上游原版只输出 .ass,这里把 .ass 还原成本程序的 jsonl,之后就能走同一套合成流程
package main
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"math"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)
// ASS 的时间格式 H:MM:SS.cc
var assClockRe = regexp.MustCompile(`^(\d+):(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?$`)
// ASS 覆盖块里的 \move(x1,y1,x2,y2)
var assMoveRe = regexp.MustCompile(`\\move\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)`)
// Style / Dialogue 里的「昵称(uid)」
var assSpeakerRe = regexp.MustCompile(`^(.*)\((\d+)\)$`)
// 文件里没有 Format 行时按标准列序解析
// 样式必须是完整的 23 列:字号是第 3 列,后面还有颜色等字段,
// 列数给少了会把后面的内容全吞进字号那一列
var (
defaultStyleFields = []string{
"Name", "Fontname", "Fontsize", "PrimaryColour", "SecondaryColour", "OutlineColour",
"BackColour", "Bold", "Italic", "Underline", "StrikeOut", "ScaleX", "ScaleY",
"Spacing", "Angle", "BorderStyle", "Outline", "Shadow", "Alignment",
"MarginL", "MarginR", "MarginV", "Encoding",
}
defaultEventFields = []string{"Layer", "Start", "End", "Style", "Name", "MarginL", "MarginR", "MarginV", "Effect", "Text"}
)
// jsonl 的摘要,检查合并参数时用
type jsonlSummary struct {
meta danmuMeta
count int
first float64
last float64
hasAvatar bool // 记录里有没有头像链接
}
// 解析 ASS 时间(H:MM:SS.cc),返回秒数
func parseAssClock(s string) (float64, bool) {
m := assClockRe.FindStringSubmatch(strings.TrimSpace(s))
if m == nil {
return 0, false
}
h, _ := strconv.Atoi(m[1])
mi, _ := strconv.Atoi(m[2])
sec, _ := strconv.Atoi(m[3])
frac := 0.0
if m[4] != "" {
frac, _ = strconv.ParseFloat("0."+m[4], 64)
}
return float64(h)*3600 + float64(mi)*60 + float64(sec) + frac, true
}
// 按 ASS 的格式拆一行:列数按 Format 行给出的数量截断,最后一列吸收剩余的逗号
// (弹幕内容里本来就可能有逗号,不能无脑 Split)
func assFields(line string, count int) []string {
body := line
if i := strings.Index(line, ":"); i >= 0 {
body = line[i+1:]
}
body = strings.TrimLeft(body, " \t")
if count > 0 {
return strings.SplitN(body, ",", count)
}
return strings.Split(body, ",")
}
// 这一行是不是某个 ASS 关键字开头(形如「Style: xxx」)
func isAssKey(line, key string) bool {
i := strings.Index(line, ":")
if i < 0 {
return false
}
return strings.EqualFold(strings.TrimSpace(line[:i]), key)
}
// 字段名在 Format 行里的下标,找不到返回 -1
func assFieldIndex(format []string, name string) int {
for i, f := range format {
if strings.EqualFold(strings.TrimSpace(f), name) {
return i
}
}
return -1
}
// 拆开 ASS 正文开头的 {...} 覆盖块和后面的文字
func splitAssOverride(text string) (string, string) {
if strings.HasPrefix(text, "{") {
if i := strings.Index(text, "}"); i >= 0 {
return text[1:i], text[i+1:]
}
}
return "", text
}
// 从 \move 的 y 坐标反推弹道行号,解析不出来返回 0
func assLane(override string, fontSize int, nick bool) int {
if fontSize <= 0 {
return 0
}
m := assMoveRe.FindStringSubmatch(override)
if m == nil {
return 0
}
y, err := strconv.ParseFloat(m[2], 64)
if err != nil {
return 0
}
lane := int(math.Round(y / float64(fontSize)))
if !nick {
// 原格式的 y 是 fontSize*(lane+1),昵称版是 fontSize*lane
lane--
}
if lane < 0 {
lane = 0
}
if lane >= laneNum {
lane = laneNum - 1
}
return lane
}
// 合成时实际的显示文字,与录制时写进 jsonl 的格式一致
func speakerText(name string, uid int64, content string) string {
return name + "(" + strconv.FormatInt(uid, 10) + "):" + content
}
// 从 ASS 文件还原弹幕记录,支持原格式(\move)和昵称头像版(\an7\move)
func parseAssFile(path string) (danmuMeta, []danmuRecord, error) {
var meta danmuMeta
f, err := os.Open(path)
if err != nil {
return meta, nil, err
}
defer f.Close()
var styleFormat, eventFormat []string
records := make([]danmuRecord, 0, 1024)
bad := 0
section := ""
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
line := strings.TrimRight(scanner.Text(), "\r")
// 有些编辑器/工具保存的 ASS 头上有 UTF-8 BOM,第一个 "[" 会被它挡住:
// 整份文件进不了任何一节,最后只会报一句「可能不是弹幕文件」,跟真实原因完全对不上
line = strings.TrimPrefix(line, "\ufeff")
t := strings.TrimSpace(line)
if t == "" || strings.HasPrefix(t, ";") {
continue
}
if strings.HasPrefix(t, "[") && strings.HasSuffix(t, "]") {
section = strings.ToLower(strings.Trim(t, "[]"))
continue
}
switch section {
case "script info":
i := strings.Index(t, ":")
if i < 0 {
continue
}
val := strings.TrimSpace(t[i+1:])
switch strings.ToLower(strings.TrimSpace(t[:i])) {
case "playresx":
meta.PlayResX, _ = strconv.Atoi(val)
case "playresy":
meta.PlayResY, _ = strconv.Atoi(val)
}
case "v4+ styles", "v4 styles":
if isAssKey(t, "format") {
styleFormat = assFields(t, 0)
continue
}
if !isAssKey(t, "style") {
continue
}
format := styleFormat
if len(format) == 0 {
format = defaultStyleFields
}
fields := assFields(t, len(format))
i := assFieldIndex(format, "fontsize")
if i < 0 || i >= len(fields) {
continue
}
v, err := strconv.Atoi(strings.TrimSpace(fields[i]))
if err != nil || v <= 0 {
continue
}
// 优先用 Danmu 这个样式,其它的只在本文件还没有字号时兜底
name := ""
if j := assFieldIndex(format, "name"); j >= 0 && j < len(fields) {
name = strings.TrimSpace(fields[j])
}
if meta.FontSize == 0 || strings.EqualFold(name, "danmu") {
meta.FontSize = v
}
case "events":
if isAssKey(t, "format") {
eventFormat = assFields(t, 0)
continue
}
if !isAssKey(t, "dialogue") {
continue
}
format := eventFormat
if len(format) == 0 {
format = defaultEventFields
}
fields := assFields(t, len(format))
get := func(name string) string {
i := assFieldIndex(format, name)
if i < 0 || i >= len(fields) {
return ""
}
return fields[i]
}
start, ok := parseAssClock(get("Start"))
if !ok {
bad++
continue
}
override, body := splitAssOverride(get("Text"))
nick := strings.Contains(override, `\an7`)
speaker := strings.TrimSpace(get("Name"))
name, uid := speaker, int64(0)
if m := assSpeakerRe.FindStringSubmatch(speaker); m != nil {
name = m[1]
uid, _ = strconv.ParseInt(m[2], 10, 64)
}
// 昵称头像版的正文前面还有一段「昵称(uid):」,原格式的正文就是弹幕内容
content := body
if nick && speaker != "" {
if p := strings.Index(content, speaker+":"); p >= 0 {
content = content[p+len(speaker)+1:]
}
}
content = strings.TrimSpace(content)
if content == "" {
bad++
continue
}
records = append(records, danmuRecord{
Time: start,
UID: uid,
Name: name,
Avatar: "", // .ass 里没有头像链接,合成时按 uid 反查
Content: content,
Lane: assLane(override, meta.FontSize, nick),
Width: textWidth(speakerText(name, uid, content), meta.FontSize),
})
}
}
if err := scanner.Err(); err != nil {
return meta, nil, err
}
if meta.PlayResX == 0 || meta.PlayResY == 0 {
return meta, nil, errors.New("ASS 文件里没有 PlayResX / PlayResY,可能不是弹幕文件")
}
if meta.FontSize == 0 {
return meta, nil, errors.New("ASS 文件里没找到字幕字号(Style: Danmu)")
}
if len(records) == 0 {
if bad > 0 {
return meta, nil, fmt.Errorf("ASS 里的 %d 行弹幕都没能解析出来", bad)
}
return meta, nil, errNoDanmu
}
// 分块渲染要求记录按时间递增,这里统一排一遍
sort.SliceStable(records, func(i, j int) bool { return records[i].Time < records[j].Time })
// 文件名前缀就是录制开始的时刻,用它算偏移最稳
meta.StartTime = nameStartNs(path)
if bad > 0 {
lPrintWarnf("%s 里有 %d 行弹幕没能解析出来,已跳过", filepath.Base(path), bad)
}
lPrintf("已从 %s 还原出 %d 条弹幕(%dx%d,字号 %d,弹幕到 %s)",
filepath.Base(path), len(records), meta.PlayResX, meta.PlayResY, meta.FontSize,
formatSeconds(records[len(records)-1].Time))
return meta, records, nil
}
// 按本程序的 jsonl 格式写文件(首行是元数据)
func writeJsonlFile(path string, meta danmuMeta, records []danmuRecord) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
w := bufio.NewWriter(f)
b, err := json.Marshal(meta)
if err != nil {
return err
}
w.Write(b)
w.WriteByte('\n')
for _, r := range records {
if b, err := json.Marshal(r); err == nil {
w.Write(b)
w.WriteByte('\n')
}
}
return w.Flush()
}
// 读取 jsonl 的摘要
func scanJsonlSummary(path string) (jsonlSummary, error) {
var s jsonlSummary
f, err := os.Open(path)
if err != nil {
return s, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
first := true
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
if first {
first = false
if err := json.Unmarshal(line, &s.meta); err != nil {
return s, fmt.Errorf("jsonl 的首行不是元数据,文件可能已损坏:%v", err)
}
continue
}
var r danmuRecord
if json.Unmarshal(line, &r) != nil || r.Content == "" {
continue
}
if s.count == 0 {
s.first = r.Time
}
s.last = r.Time
s.count++
if r.Avatar != "" {
s.hasAvatar = true
}
}
if err := scanner.Err(); err != nil {
return s, err
}
if s.meta.PlayResX == 0 || s.meta.FontSize == 0 {
return s, errors.New("jsonl 文件格式不对,首行必须包含 playResX、playResY、fontSize")
}
return s, nil
}
// 弹幕文件对应的 jsonl 路径:xxx.ass 和 xxx.nick.ass 都对应 xxx.jsonl
func assSiblingJsonl(path string) string {
base, low := path, strings.ToLower(path)
switch {
case strings.HasSuffix(low, ".nick.ass"):
base = base[:len(base)-len(".nick.ass")]
case strings.HasSuffix(low, ".ass"):
base = base[:len(base)-len(".ass")]
default:
return ""
}
return base + ".jsonl"
}
// 弹幕文件名去掉扩展名(.nick.ass 也一起去掉),用来拼默认的输出名
func danmuBaseName(path string) string {
name := filepath.Base(path)
low := strings.ToLower(name)
switch {
case strings.HasSuffix(low, ".nick.ass"):
return name[:len(name)-len(".nick.ass")]
case strings.HasSuffix(low, ".ass"), strings.HasSuffix(low, ".jsonl"):
return name[:len(name)-len(filepath.Ext(name))]
}
return name
}