Files
acfunlive-remake/recordjsonl_test.go
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

271 lines
8.6 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.
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/orzogc/acfundanmu"
)
// 等替换过的后台任务全部收工。用例里触发了 fire-and-forget 的合并时,必须在用例结束前等它们跑完
func waitBackground(t *testing.T, bg *sync.WaitGroup, timeout time.Duration) {
t.Helper()
done := make(chan struct{})
go func() {
bg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(timeout):
t.Error("等后台任务收工超时,它可能还在读全局配置")
}
}
// 用真正的录制写入器(danmuWriter)产生 jsonl,再按合并的流程重排一遍,
// 验证「本程序自己录下来的弹幕」合出来和以前一模一样(弹道、宽度都不变)。
// 这也是手动合并与自动合并的分水岭:录制侧写文件完全不经过 relayoutLanes,
// 只有合并渲染前会重排,所以磁盘上的 .ass/.nick.ass/.jsonl 一个字节都不变。
func TestLiveRecordingPathUnaffected(t *testing.T) {
dir := t.TempDir()
cfg := acfundanmu.SubConfig{Title: "测试直播", PlayResX: 1920, PlayResY: 1080,
FontSize: 48, StartTime: 1700000000000000000}
ass := filepath.Join(dir, "live.ass")
w := newDanmuWriter("liveid", "stream", cfg, ass)
texts := []string{"来啦", "这首歌好听", "主播加油啊", "打卡打卡", "哈哈哈哈哈哈哈哈哈",
"这条弹幕稍微长一点看看弹道到底怎么排"}
for i := 0; i < 500; i++ {
relMs := int64(i) * 220 // 平均 0.22 秒一条,够密集
c := &acfundanmu.Comment{Content: texts[i%len(texts)]}
c.SendTime = (cfg.StartTime + relMs*1e6) / 1e6
c.UserID = int64(2000 + i%9)
c.Nickname = "观众" + string(rune('A'+i%9))
c.Avatar = "http://example.invalid/a.png"
w.write(c)
}
w.close()
f, err := os.Open(filepath.Join(dir, "live.jsonl"))
if err != nil {
t.Fatal(err)
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 1<<20), 1<<20)
var meta danmuMeta
first := true
var recs []danmuRecord
for sc.Scan() {
if first {
json.Unmarshal(sc.Bytes(), &meta)
first = false
continue
}
var r danmuRecord
if json.Unmarshal(sc.Bytes(), &r) == nil {
recs = append(recs, r)
}
}
type snap struct{ lane, width int }
before := make([]snap, len(recs))
for i, r := range recs {
before[i] = snap{r.Lane, r.Width}
}
relayoutLanes(recs, meta)
laneDiff, widthDiff := 0, 0
for i, r := range recs {
if r.Lane != before[i].lane {
laneDiff++
if laneDiff <= 3 {
t.Logf("第 %d 条弹道变了:%d → %d", i, before[i].lane, r.Lane)
}
}
if r.Width != before[i].width {
widthDiff++
}
}
t.Logf("录制产生 %d 条弹幕(含空内容 %d 条),重排后弹道变化 %d 条、宽度变化 %d 条",
len(recs), countEmpty(recs), laneDiff, widthDiff)
if laneDiff != 0 || widthDiff != 0 {
t.Errorf("正常录制的弹幕重排后不应有任何变化")
}
}
func countEmpty(recs []danmuRecord) int {
n := 0
for _, r := range recs {
if r.Content == "" {
n++
}
}
return n
}
// ==================== 写盘出错必须能被发现 ====================
// 弹幕是边录边写的,写盘失败(磁盘满、外接盘/网络盘掉线)如果没人管,
// 程序会照常宣告「录制完成」并自动合并 —— 出来的成片缺一大截弹幕,
// 而日志里什么都看不出来。下面几条例外情况锁住「失败要浮出来」。
// 写入失败必须被记下来,并且在 close() 时交出来
func TestDanmuWriteErrorSurfacedOnClose(t *testing.T) {
dir := t.TempDir()
cfg := acfundanmu.SubConfig{Title: "测试直播", PlayResX: 1920, PlayResY: 1080,
FontSize: 48, StartTime: 1700000000000000000}
ass := filepath.Join(dir, "live.ass")
w := newDanmuWriter("liveid", "stream", cfg, ass)
if w.failed() {
t.Fatalf("刚建好的写入器不该有错误,实际:%v", w.err)
}
// 用「写到已关闭的句柄」来走同一条错误路径(和磁盘满、盘掉线一样,
// 都是 os.File.Write 返回 error)。换掉之前先把原来的句柄关掉,别泄漏
if err := w.jsonl.Close(); err != nil {
t.Fatal(err)
}
dead, err := os.Open(filepath.Join(dir, "live.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := dead.Close(); err != nil {
t.Fatal(err)
}
w.jsonl = dead
c := &acfundanmu.Comment{Content: "这条应该写失败"}
c.SendTime = (cfg.StartTime + int64(500)*1e6) / 1e6
c.UserID = 7
c.Nickname = "观众"
w.write(c)
if !w.failed() {
t.Fatal("写入失败之后 failed() 应为 true(改之前错误是被丢掉的)")
}
err = w.close()
if err == nil {
t.Fatal("close() 必须把写盘错误交出来,实际返回 nil")
}
if !strings.Contains(err.Error(), "jsonl") {
t.Errorf("错误信息里要能看出是哪个文件出的问题,实际:%v", err)
}
}
// 只记第一个错:磁盘满了之后每写一条都会失败,全记下来会把日志刷爆
func TestDanmuWriteKeepsOnlyFirstError(t *testing.T) {
w := &danmuWriter{}
w.fail("第一次", errors.New("磁盘满了"))
first := w.err
w.fail("第二次", errors.New("还是写不进去"))
w.fail("第三次", errors.New("依旧写不进去"))
if w.err != first {
t.Fatalf("错误应保持在第一个,实际被后来的覆盖了:%v", w.err)
}
if !strings.Contains(w.err.Error(), "磁盘满了") {
t.Errorf("应保留第一个错误的原因,实际:%v", w.err)
}
}
// 正常录制不能被误判成失败(这是「加检查」最容易踩的反向坑)
func TestDanmuWriteNoFalseAlarm(t *testing.T) {
dir := t.TempDir()
cfg := acfundanmu.SubConfig{Title: "测试直播", PlayResX: 1920, PlayResY: 1080,
FontSize: 48, StartTime: 1700000000000000000}
w := newDanmuWriter("liveid", "stream", cfg, filepath.Join(dir, "ok.ass"))
for i := 0; i < 30; i++ {
c := &acfundanmu.Comment{Content: "正常弹幕"}
c.SendTime = (cfg.StartTime + int64(i)*220*1e6) / 1e6
c.UserID = int64(100 + i)
c.Nickname = "观众"
w.write(c)
}
if w.failed() {
t.Fatalf("正常写入不该判失败:%v", w.err)
}
if err := w.close(); err != nil {
t.Fatalf("正常写完后 close() 应返回 nil,实际:%v", err)
}
}
// 弹幕文件写坏之后必须跳过自动合并:只是打一条日志的话,
// 照样会合成一个「缺了一大截弹幕」的成片,而且是静默的
func TestFinishPartSkipsMergeWhenDanmuBroken(t *testing.T) {
// 这个用例会真的触发自动合并(safeGo 起的 goroutine)。它跑到用例结束之后还会读全局配置,
// 而下一个用例开头就改配置 → -race 会判成数据竞争。所以这里把后台任务换成可等待的版本,
// 清理时先等它收工再还原全局变量
var bg sync.WaitGroup
oldRun := runBackground
runBackground = func(f func()) {
bg.Add(1)
go func() {
defer bg.Done()
f()
}()
}
oldAuto, oldCtx := config.AutoMerge, mainCtx
t.Cleanup(func() {
waitBackground(t, &bg, 60*time.Second)
config.AutoMerge, mainCtx = oldAuto, oldCtx
runBackground = oldRun
})
config.AutoMerge = true
mainCtx = context.Background()
dir := t.TempDir()
video := filepath.Join(dir, "v.mp4")
ass := filepath.Join(dir, "v.ass")
if err := os.WriteFile(video, []byte("fake"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(ass, []byte("[Script Info]"), 0644); err != nil {
t.Fatal(err)
}
// 测试里没跑 initialize(),这张表还没建起来
lInfoMap.Lock()
if lInfoMap.info == nil {
lInfoMap.info = make(map[string]liveInfo)
}
lInfoMap.Unlock()
// 对照组:一切都正常时应该触发合并
good := "good-live-id"
lInfoMap.Lock()
lInfoMap.info[good] = liveInfo{LiveID: good, uid: 1, assFile: ass, firstRecordFile: video,
recordStartNs: time.Now().UnixNano(), recordSegments: []recordSegment{{file: video, startNs: time.Now().UnixNano()}}}
lInfoMap.Unlock()
finishPart(good, true)
finishPart(good, false)
lInfoMap.RLock()
started := lInfoMap.info[good].mergeStarted
lInfoMap.RUnlock()
if !started {
t.Fatal("对照组:条件都满足时应当触发自动合并(否则这条测试本身没意义)")
}
// 实验组:同样条件,但弹幕写盘出过错
bad := "bad-live-id"
lInfoMap.Lock()
lInfoMap.info[bad] = liveInfo{LiveID: bad, uid: 2, assFile: ass, firstRecordFile: video,
danmuWriteFailed: true,
recordStartNs: time.Now().UnixNano(), recordSegments: []recordSegment{{file: video, startNs: time.Now().UnixNano()}}}
lInfoMap.Unlock()
finishPart(bad, true)
finishPart(bad, false)
lInfoMap.RLock()
got := lInfoMap.info[bad].mergeStarted
lInfoMap.RUnlock()
if got {
t.Error("弹幕文件写坏过,不该触发自动合并(否则会合成一个缺弹幕的成片)")
}
}