init
This commit is contained in:
+400
@@ -0,0 +1,400 @@
|
||||
// 弹幕文件的读写与格式还原
|
||||
// 上游原版只输出 .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
|
||||
}
|
||||
Reference in New Issue
Block a user