package main import ( "bytes" "fmt" "os" "os/exec" "path/filepath" "strings" "testing" ) // 造 n 条弹幕:从 start 秒开始,每条间隔 step func mkDanmuForFit(start, step float64, n int) []danmuRecord { out := make([]danmuRecord, 0, n) for i := 0; i < n; i++ { out = append(out, danmuRecord{ Time: start + float64(i)*step, UID: int64(100 + i), Name: "甲", Content: fmt.Sprintf("第 %d 条", i), Width: 100, }) } return out } // 正常场次:时间轴一点都不该动 func TestFitDanmuToVideoLeavesNormalAlone(t *testing.T) { records := mkDanmuForFit(1, 1, 10) // 1 ~ 10 秒 got, off := fitDanmuToVideo(records, 0, 60) if len(got) != 10 || off != 0 { t.Fatalf("正常场次不该动:留 %d 条、偏移 %v", len(got), off) } } // 弹幕确实录得比视频久(视频中途断过):只丢尾部超出的,时间轴不动 func TestFitDanmuToVideoDropsTailOnly(t *testing.T) { records := mkDanmuForFit(1, 5, 20) // 1 ~ 96 秒,视频只有 50 秒 got, off := fitDanmuToVideo(records, 0, 50) if off != 0 { t.Fatalf("只该丢尾部,偏移应保持 0,实际 %v", off) } if len(got) == 0 || len(got) == len(records) { t.Fatalf("应该留一部分、丢一部分,实际留 %d 条", len(got)) } if last := got[len(got)-1].Time; last >= 50 { t.Fatalf("留下的最后一条应在视频内,实际 %v", last) } } // 时间基准整体偏移的 .ass(时间相对程序启动、不是录制开始):按第一条弹幕对齐视频起点 func TestFitDanmuToVideoAlignsShiftedTimeBase(t *testing.T) { // 现实素材的形状:7849.92 ~ 13286.59 秒 / 视频 5456.883 秒 records := mkDanmuForFit(7849.92, 5.7, 955) got, off := fitDanmuToVideo(records, 0, 5456.883) if off != -7849.92 { t.Fatalf("偏移应为 -7849.92,实际 %v", off) } if len(got) != len(records) { t.Fatalf("跨度装得进视频,不该丢弹幕,实际留 %d / %d 条", len(got), len(records)) } if first := got[0].Time + off; first != 0 { t.Fatalf("第一条应落在视频起点,实际 %v", first) } if end := got[len(got)-1].Time + off; end >= 5456.883 { t.Fatalf("最后一条应在视频内,实际 %v", end) } } // 边界:跨度刚好比视频短一点点,对齐后一条都不该丢 func TestFitDanmuToVideoAlignJustFits(t *testing.T) { records := mkDanmuForFit(1000, 1, 1000) // 1000 ~ 1999 秒,跨度 999 秒 got, off := fitDanmuToVideo(records, 0, 1000) if off != -1000 { t.Fatalf("偏移应为 -1000,实际 %v", off) } if len(got) != len(records) { t.Fatalf("跨度装得进视频,不该丢弹幕,实际留 %d / %d 条", len(got), len(records)) } if end := got[len(got)-1].Time + off; end >= 1000 { t.Fatalf("最后一条应在视频内,实际 %v", end) } } // 弹幕跨度本身就装不进视频:平移救不回来,只能按原行为一条不留 func TestFitDanmuToVideoGivesUpWhenSpanTooLong(t *testing.T) { records := mkDanmuForFit(7849.92, 10, 100) // 跨度 990 秒 > 视频 500 秒 got, off := fitDanmuToVideo(records, 0, 500) if len(got) != 0 { t.Fatalf("跨度装不下时应一条不留,实际 %d 条", len(got)) } if off != 0 { t.Fatalf("放弃时不该改偏移,实际 %v", off) } } // 偏移把弹幕整体推到视频之前时保持原行为(这些条目不算「超出时长」,由渲染那边处理) func TestFitDanmuToVideoKeepsAllBeforeVideoBehaviour(t *testing.T) { records := mkDanmuForFit(100, 1, 10) got, off := fitDanmuToVideo(records, -1000, 500) if len(got) != len(records) || off != -1000 { t.Fatalf("整体在视频之前时不该动:留 %d 条、偏移 %v", len(got), off) } } // 用户实测的那种场次:.ass 时间基准比视频晚一大截,合并要能成功并且真的画上弹幕 func TestManualMergeFromShiftedAssEndToEnd(t *testing.T) { dir := setupMergeTest(t) ffmpeg := getFFmpeg() if ffmpeg == "" { t.Skip("没有 ffmpeg") } name := "2026-09-25 02-00-00 测试主播 时间基准偏移" video := filepath.Join(recordSaveDir(), name+".mp4") cmd := exec.Command(ffmpeg, "-v", "error", "-y", "-f", "lavfi", "-i", "color=c=black:size=640x360:rate=25", "-t", "8", "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", video) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("生成测试视频失败:%v\n%s", err, out) } // 弹幕时间整体比录制起点晚 2 小时(模拟「程序先开着、直播中途才开始录」) ass := fmt.Sprintf(`[Script Info] Title: %s ScriptType: v4.00+ Collisions: Normal PlayResX: 640 PlayResY: 360 [V4+ Styles] Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding Style: Danmu,Microsoft YaHei,40,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,20,20,2,0 [Events] Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text Dialogue: 0,2:00:01.00,2:00:11.00,Danmu,甲(101),20,20,2,,{\move(400,40,-40,40)}第一条弹幕 Dialogue: 0,2:00:03.00,2:00:13.00,Danmu,乙(202),20,20,2,,{\move(400,80,-40,80)}第二条弹幕 Dialogue: 0,2:00:05.00,2:00:15.00,Danmu,甲(101),20,20,2,,{\move(400,120,-40,120)}第三条弹幕 `, name) assPath := writeTestFile(t, filepath.Join(recordSaveDir(), name+".ass"), ass) if _, err := prepareDanmuSource(assPath, true); err != nil { t.Fatalf("还原弹幕失败:%v", err) } plan, err := buildMergePlan(mergeRequest{Videos: []string{video}, Danmu: assPath}) if err != nil { t.Fatalf("检查失败:%v", err) } var buf bytes.Buffer oldOut := logger.Writer() logger.SetOutput(&buf) err = execManualMerge(plan) logger.SetOutput(oldOut) if err != nil { t.Fatalf("时间基准偏移的场次应当能合出来,实际失败:%v\n日志:\n%s", err, buf.String()) } log := buf.String() if !strings.Contains(log, "按「第一条弹幕对齐视频起点」") { t.Fatalf("日志里应写明自动对齐:\n%s", log) } if !strings.Contains(log, "对齐后弹幕覆盖") { t.Fatalf("日志里应写明对齐后的覆盖范围:\n%s", log) } if _, err := os.Stat(plan.out); err != nil { t.Fatalf("没有生成成片:%v", err) } // 成片里确实画了弹幕:同一时刻抽帧,有字的帧 PNG 一定比纯黑源片大 shot := func(src, tag string) int { out := filepath.Join(dir, "shot_"+tag+".png") c := exec.Command(ffmpeg, "-v", "error", "-y", "-ss", "3", "-i", src, "-frames:v", "1", "-update", "1", out) if o, err := c.CombinedOutput(); err != nil { t.Fatalf("抽帧失败:%v\n%s", err, o) } fi, err := os.Stat(out) if err != nil { t.Fatal(err) } return int(fi.Size()) } srcSize, outSize := shot(video, "src"), shot(plan.out, "out") if outSize <= srcSize { t.Fatalf("成片的帧没有比纯黑源片大,弹幕很可能没画上:源 %d 字节、成片 %d 字节", srcSize, outSize) } t.Logf("抽帧大小:源 %d 字节、成片 %d 字节", srcSize, outSize) } // 同一次 .ass 还原只该在日志里出现一条(页面的「检查」和合并都会解析一遍) func TestPrepareDanmuSourceLogsRestoreOnce(t *testing.T) { setupMergeTest(t) path := writeTestFile(t, filepath.Join(recordSaveDir(), "2026-09-25 03-00-00 测试主播 标题.ass"), plainAssFixture) var buf bytes.Buffer oldOut := logger.Writer() logger.SetOutput(&buf) defer logger.SetOutput(oldOut) // 「检查」先解析一遍(不落盘),合并再解析一遍并落盘 if _, err := prepareDanmuSource(path, false); err != nil { t.Fatalf("检查失败:%v", err) } if _, err := prepareDanmuSource(path, true); err != nil { t.Fatalf("还原失败:%v", err) } log := buf.String() if n := strings.Count(log, "还原出"); n != 1 { t.Fatalf("「还原出」应只出现 1 次,实际 %d 次:\n%s", n, log) } if n := strings.Count(log, "还原成"); n != 0 { t.Fatalf("不该再有「还原成」这条日志,实际 %d 次:\n%s", n, log) } if !strings.Contains(log, filepath.Base(path)) { t.Fatalf("日志里应写明是哪个文件:\n%s", log) } }