一、弹幕滚动时间不再整数除零 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 项全过
404 lines
11 KiB
Go
404 lines
11 KiB
Go
// 弹幕文件的读写与格式还原
|
||
// 上游原版只输出 .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
|
||
}
|