1. 整场弹幕都被判「超出视频时长」,合并直接取消 上游原版录出来的 .ass 时间不是相对录制起点,而是相对弹幕客户端连上房间的时刻。 程序先开着、直播中途才开始录的场次,这份 .ass 的零点会比视频起点早一大截 (用户实测那份早了 7849.92 秒:955 条弹幕、跨度 5436.7 秒,视频只有 5456.9 秒)。 一律按「视频时间 = 弹幕时间 + 偏移」算的话,这 955 条全都落在 5456.9 秒之外, 于是「有 955 条弹幕超出了录播视频的时长,已跳过」→「弹幕文件里没有弹幕,取消手动合并」, 一分半的录播一条弹幕都合不出来。 这类偏移是能救的:跨度(末 - 首)5436.7 秒装得进 5456.9 秒,整体前移 7849.92 秒之后 覆盖 0 ~ 5436.7 秒、尾部正好空 20.2 秒 —— 和正常录播的尾部空隙(实测 6~20 秒)吻合, 是时间基准偏移,不是文件配错。 新增 fitDanmuToVideo,在探到视频时长之后、裁剪之前介入,触发条件收得很紧: 只有「按现偏移一条弹幕都用不上」**且**「弹幕跨度 < 视频时长」才整体平移,把第一条 对齐到视频起点,日志写明平移量、对齐后的覆盖范围和尾部空隙,并提示整体偏早或偏晚 可以在「偏移」里微调。其余情况完全保持原行为:只是一部分超出 → 照旧只丢尾部; 跨度本身就装不下 → 一条不留。这样不可能影响任何现在能正常合并的场次。 2. 「已从 xxx.ass 还原出 N 条弹幕」会打印两遍 页面的「检查」和真正合并会各解析一遍 .ass,而这条日志写在被复用的 parseAssFile 里, 于是同一句话出现两次,第二遍后面还跟着「已把 xxx.ass 还原成 xxx.jsonl」,看不出谁是谁。 把这两条日志从 parseAssFile 里摘出去(它改成返回坏行数 bad,由调用方决定要不要告警), 交给真正落盘那次的调用方 prepareDanmuSource(path, true) 打印,现在还会写明写成了 哪个文件;坏行警告统一走新增的 logAssBadLines,不再重复。 顺手给「最后一条弹幕超出视频总长」那条警告补了指引,说明合成时会自动对齐。 3. 测试里后台任务漏到用例结束之后 safeGo 原来直接把 goroutine 扔出去,用例结束它还在读全局配置,下一个用例开头一改配置, -race 就判成数据竞争(而且报在后面的用例上,很难看出真凶)。把「怎么起后台任务」抽成 var runBackground,生产行为不变,测试里换成可等待的版本,清理时等它收工。 注意:这个竞争单独 -run 一个用例复现不出来(-race -count=3 也过),必须整包跑 go test -race . 才会在后续用例上报 race detected。 验证 - gofmt 无输出 / go vet 通过 / go test(182 个,18.5s)全过 / go test -race 整包跑 20 轮 (13 轮顺序 + 6 轮 -shuffle=on)全过 / staticcheck 无输出 / GOOS=linux go build 通过。 另:有一轮 -race 曾报过 FAIL、随后 20 轮都没能复现,怀疑是 TestDownloadAvatarCapsResponseBody 自带的抖动(它的断言依赖「服务端写不完 10MB」,受内核缓冲大小影响),与本批改动无关, 该文件本批也没动过;待单独确认。 - 反向验证从 32 项扩到 37 项全过(新增:自动对齐 / 只丢尾部不平移 / 跨度装不进不强平移 / 还原日志只打一次 / 后台任务可等待);脚本新增单用例附加参数 "args" 与不带 -run 的整包用例。 - 用用户那份真实素材(955 条,7849.92 ~ 13286.59 秒,视频 5456.883 秒)端到端跑通: 不填偏移 + 5460 秒视频 → 日志「弹幕时间基准和视频对不上:955 条弹幕全部落在视频时长 (5460.0 秒)之外,最早那条在 7849.9 秒;已按「第一条弹幕对齐视频起点」整体前移 7849.9 秒(偏移 0.0 → -7849.9)」「对齐后弹幕覆盖 0.0 ~ 5436.7 秒,视频总长 5460.0 秒 (尾部空 23.3 秒)」,分 5 块渲染,exit=0,成片 5462.31 秒 / 23.3 MB。 再按 jsonl + 偏移反推「哪些时刻该有弹幕」,随机取 40 个应有时刻 + 20 个任意弹幕窗口都 不覆盖的空屏时刻,抽帧数白像素(白字黑底):60/60 全对,跨块边界(1116/2435/3393/4408) 与最尾部 t=5435.7 都对得上。 - 60 秒小片新旧对照(真实 9 条弹幕、原始时间戳 7849.92 ~ 7908.7):旧版「有 9 条弹幕超出了 录播视频的时长(60.1 秒),已跳过」+「jsonl 里没有弹幕」、无成片;新版自动前移 7849.9 秒、 9 条全画上、成片 34 MB。抽帧看轨迹仍是匀速 10 秒穿屏(danmuShowSeconds), 头像跟着文字同步走,没有回归。
400 lines
11 KiB
Go
400 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)
|
||
// 第二个返回值是没能解析出来的行数,日志交给调用方打:页面的「检查」和真正的合并
|
||
// 会各解析一遍同一个 .ass,日志只该出现一次
|
||
func parseAssFile(path string) (danmuMeta, []danmuRecord, int, error) {
|
||
var meta danmuMeta
|
||
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return meta, nil, 0, 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, bad, err
|
||
}
|
||
|
||
if meta.PlayResX == 0 || meta.PlayResY == 0 {
|
||
return meta, nil, bad, errors.New("ASS 文件里没有 PlayResX / PlayResY,可能不是弹幕文件")
|
||
}
|
||
if meta.FontSize == 0 {
|
||
return meta, nil, bad, errors.New("ASS 文件里没找到字幕字号(Style: Danmu)")
|
||
}
|
||
if len(records) == 0 {
|
||
if bad > 0 {
|
||
return meta, nil, bad, fmt.Errorf("ASS 里的 %d 行弹幕都没能解析出来", bad)
|
||
}
|
||
return meta, nil, bad, errNoDanmu
|
||
}
|
||
|
||
// 分块渲染要求记录按时间递增,这里统一排一遍
|
||
sort.SliceStable(records, func(i, j int) bool { return records[i].Time < records[j].Time })
|
||
// 文件名前缀就是录制开始的时刻,用它算偏移最稳
|
||
meta.StartTime = nameStartNs(path)
|
||
return meta, records, bad, 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
|
||
}
|