Files
tamakyi 3e16e78003 .ass 时间基准和视频对不上时按第一条弹幕自动对齐,并去掉重复的还原日志
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),
  头像跟着文字同步走,没有回归。
2026-09-26 17:51:43 +08:00

400 lines
11 KiB
Go
Raw Permalink 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)
// 第二个返回值是没能解析出来的行数,日志交给调用方打:页面的「检查」和真正的合并
// 会各解析一遍同一个 .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
}