// 弹幕文件的读写与格式还原 // 上游原版只输出 .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") 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 }