Files
acfunlive-remake/ass2jsonl.go
T
2026-09-25 13:16:54 +08:00

401 lines
10 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")
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
}