package main import ( "fmt" "os" "os/exec" "path/filepath" "strings" "testing" "time" ) // 原格式 ASS(上游原版用 \move,弹幕正文是裸的) const plainAssFixture = `[Script Info] ; LiveID: 123456 ; StreamName: 测试主播 Title: 2026-09-25 01-00-00 测试主播 标题 ScriptType: v4.00+ Collisions: Normal PlayResX: 1920 PlayResY: 1080 [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,48,&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,0:00:01.00,0:00:11.00,Danmu,甲(101),20,20,2,,{\move(1020,48,-60,48)}你好, 世界 Dialogue: 0,0:00:04.00,0:00:14.00,Danmu,乙(202),20,20,2,,{\move(1068,96,-120,96)}第二条弹幕 Comment: 0,0:00:05.00,0:00:15.00,Danmu,丙(303),20,20,2,,{\move(1068,96,-120,96)}这行是注释不该被读进来 ` // 昵称头像版 ASS(\an7\move,正文前面带「昵称(uid):」) const nickAssFixture = `[Script Info] Title: 2026-09-25 01-00-00 测试主播 标题 昵称头像版 ScriptType: v4.00+ Collisions: Normal PlayResX: 1920 PlayResY: 1080 [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,48,&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,0:00:02.00,0:00:12.00,Danmu,甲(101),20,20,2,,{\an7\move(1966,48,-200,48)}甲(101):昵称版弹幕 ` // 没有 Format 行时按标准列序兜底 const noFormatAssFixture = `[Script Info] Title: 无 Format 行 PlayResX: 640 PlayResY: 360 [V4+ Styles] Style: Danmu,Microsoft YaHei,24,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,20,20,2,0 [Events] Dialogue: 0,0:00:03.50,0:00:13.50,Danmu,丙(303),20,20,2,,{\move(400,24,-40,24)}兜底解析 ` // 测试用的 ffmpeg,找不到就跳过 // 依次找 PATH 和源码目录(以及上一级目录)里的 ffmpeg,不写死某台机器上的路径 // 按真实链路跑一次手动合并:独占任务位,跑完一定要收尾。 // 不能直接调 runManualMerge —— 上一个用例如果没收尾,jobStart 会拒掉这一场 // (正是它的职责),测试就会莫名其妙失败 func execManualMerge(plan mergePlan) error { jobFinish("") err := runMergeExclusive(func() error { return runManualMerge(plan) }) jobFinish(jobErrText(err)) return err } func testFFmpeg(t *testing.T) string { t.Helper() cands := []string{"ffmpeg"} for _, name := range []string{"ffmpeg.exe", filepath.Join("..", "ffmpeg.exe")} { if abs, err := filepath.Abs(name); err == nil { cands = append(cands, abs) } } if config.FFmpegPath != "" { cands = append([]string{config.FFmpegPath}, cands...) } for _, p := range cands { if p == "" { continue } if err := exec.Command(p, "-version").Run(); err == nil { return p } } t.Skip("机器上没有可用的 ffmpeg,跳过") return "" } // 准备一个干净的保存目录 func setupMergeTest(t *testing.T) string { t.Helper() dir := t.TempDir() config.Directory = dir config.RecordSubdir = defaultRecordSubdir config.MergeSubdir = defaultMergeSubdir config.MergeOutputDir = "" config.MergeBitrate = 0 config.FFmpegPath = testFFmpeg(t) // recordDir 是命令行参数,测试里没跑 argsHandle 时还是 nil; // 这里兜底分配,让需要模拟 -record 相对路径的用例能改到它 if recordDir == nil { recordDir = new(string) } *recordDir = dir if err := os.MkdirAll(recordSaveDir(), 0755); err != nil { t.Fatal(err) } if err := os.MkdirAll(mergeSaveDir(), 0755); err != nil { t.Fatal(err) } // 测试统一不发网络请求,头像反查直接返回空 old := lookupAvatarURL lookupAvatarURL = func(int64) string { return "" } t.Cleanup(func() { lookupAvatarURL = old }) return dir } func writeTestFile(t *testing.T, path, content string) string { t.Helper() if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatal(err) } return path } // 解析 ASS 时间 func TestParseAssClock(t *testing.T) { cases := []struct { in string want float64 ok bool }{ {"0:00:01.00", 1, true}, {"1:02:03.45", 3723.45, true}, {"0:00:00.10", 0.1, true}, {" 0:10:00.00 ", 600, true}, {"", 0, false}, {"abc", 0, false}, } for _, c := range cases { got, ok := parseAssClock(c.in) if ok != c.ok { t.Fatalf("parseAssClock(%q) ok=%v,期望 %v", c.in, ok, c.ok) } if ok && got != c.want { t.Fatalf("parseAssClock(%q)=%v,期望 %v", c.in, got, c.want) } } } // 原格式 ASS:正文是裸的,弹道要用 y 减 1 func TestParseAssFilePlain(t *testing.T) { dir := setupMergeTest(t) path := writeTestFile(t, filepath.Join(dir, "2026-09-25 01-00-00 测试主播 标题.ass"), plainAssFixture) meta, records, err := parseAssFile(path) if err != nil { t.Fatalf("解析失败:%v", err) } if meta.PlayResX != 1920 || meta.PlayResY != 1080 || meta.FontSize != 48 { t.Fatalf("元数据不对:%+v", meta) } // Comment 行不该被读进来 if len(records) != 2 { t.Fatalf("应该解析出 2 条弹幕,实际 %d 条:%+v", len(records), records) } r0 := records[0] if r0.Time != 1 || r0.UID != 101 || r0.Name != "甲" { t.Fatalf("第一条的时间/uid/昵称不对:%+v", r0) } // 正文里的 ASCII 逗号必须保留(Text 是最后一列,不能被拆开) if r0.Content != "你好, 世界" { t.Fatalf("第一条正文不对:%q", r0.Content) } if r0.Lane != 0 { t.Fatalf("y=48、字号=48,原格式弹道应为 0,实际 %d", r0.Lane) } if r0.Avatar != "" { t.Fatalf("ASS 里没有头像链接,应为空,实际 %q", r0.Avatar) } if records[1].Lane != 1 { t.Fatalf("y=96 应为弹道 1,实际 %d", records[1].Lane) } // 文件名里的录制时间要能被解析出来 want := time.Date(2026, 9, 25, 1, 0, 0, 0, time.Local).UnixNano() if meta.StartTime != want { t.Fatalf("StartTime=%d,期望 %d", meta.StartTime, want) } } // 昵称头像版 ASS:正文要剥掉「昵称(uid):」,弹道直接用 y 除字号 func TestParseAssFileNick(t *testing.T) { dir := setupMergeTest(t) path := writeTestFile(t, filepath.Join(dir, "2026-09-25 01-00-00 测试主播 标题.nick.ass"), nickAssFixture) meta, records, err := parseAssFile(path) if err != nil { t.Fatalf("解析失败:%v", err) } if meta.FontSize != 48 { t.Fatalf("字号不对:%d", meta.FontSize) } if len(records) != 1 { t.Fatalf("应该解析出 1 条弹幕,实际 %d 条", len(records)) } r := records[0] if r.Content != "昵称版弹幕" { t.Fatalf("正文没剥掉昵称前缀:%q", r.Content) } if r.Name != "甲" || r.UID != 101 { t.Fatalf("昵称/uid 不对:%+v", r) } // 昵称版的 y 是 字号*lane,y=48、字号=48 → 弹道 1 if r.Lane != 1 { t.Fatalf("昵称版 y=48 应为弹道 1,实际 %d", r.Lane) } // 宽度按合成时真正的显示文字算:甲(101):昵称版弹幕 if want := textWidth("甲(101):昵称版弹幕", 48); r.Width != want { t.Fatalf("宽度=%d,期望 %d", r.Width, want) } if want := 432; r.Width != want { t.Fatalf("宽度期望 432(甲 48 + ( 24 + 101 72 + ) 24 + : 24 + 5 个汉字 240),实际 %d", r.Width) } } // 文件里没有 Format 行时按标准列序解析 func TestParseAssFileWithoutFormatLine(t *testing.T) { dir := setupMergeTest(t) path := writeTestFile(t, filepath.Join(dir, "noformat.ass"), noFormatAssFixture) meta, records, err := parseAssFile(path) if err != nil { t.Fatalf("解析失败:%v", err) } if meta.FontSize != 24 { t.Fatalf("没有 Format 行时应按「Name, Fontname, Fontsize」取到 24,实际 %d", meta.FontSize) } if len(records) != 1 || records[0].Content != "兜底解析" { t.Fatalf("记录不对:%+v", records) } if records[0].Time != 3.5 { t.Fatalf("时间应为 3.5,实际 %v", records[0].Time) } // 文件名没有时间戳,StartTime 应该是 0 if records[0].Time == 0 { t.Fatal("时间不该是 0") } } // 不是弹幕文件的 ASS 要报错,而不是解析出空结果 func TestParseAssFileRejectsBadFile(t *testing.T) { dir := setupMergeTest(t) cases := map[string]string{ "没有分辨率.ass": "[Script Info]\nTitle: x\n\n[Events]\nDialogue: 0,0:00:01.00,0:00:02.00,Danmu,甲(1),0,0,0,,{\\move(1,1,-1,1)}x\n", "没有字号.ass": "[Script Info]\nPlayResX: 640\nPlayResY: 360\n\n[Events]\nDialogue: 0,0:00:01.00,0:00:02.00,Danmu,甲(1),0,0,0,,{\\move(1,1,-1,1)}x\n", "没有弹幕.ass": "[Script Info]\nPlayResX: 640\nPlayResY: 360\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize\nStyle: Danmu,Microsoft YaHei,24\n", } for name, content := range cases { path := writeTestFile(t, filepath.Join(dir, name), content) if _, _, err := parseAssFile(path); err == nil { t.Fatalf("%s 应该报错", name) } } } // .ass / .nick.ass 对应的 jsonl 路径 func TestAssSiblingJsonl(t *testing.T) { cases := map[string]string{ "/a/b/2026-09-25 01-00-00 主播 标题.ass": "/a/b/2026-09-25 01-00-00 主播 标题.jsonl", "/a/b/2026-09-25 01-00-00 主播 标题.nick.ass": "/a/b/2026-09-25 01-00-00 主播 标题.jsonl", // 扩展名大小写不敏感,上游在 Linux 上录的可能就是大写 "/a/b/2026-09-25 01-00-00 主播 标题.ASS": "/a/b/2026-09-25 01-00-00 主播 标题.jsonl", "/a/b/x.mp4": "", } for in, want := range cases { if got := assSiblingJsonl(in); got != want { t.Fatalf("assSiblingJsonl(%q)=%q,期望 %q", in, got, want) } } } // 输出文件名的默认值 func TestDanmuBaseName(t *testing.T) { cases := map[string]string{ "2026-09-25 01-00-00 主播 标题.jsonl": "2026-09-25 01-00-00 主播 标题", "2026-09-25 01-00-00 主播 标题.ass": "2026-09-25 01-00-00 主播 标题", "2026-09-25 01-00-00 主播 标题.nick.ass": "2026-09-25 01-00-00 主播 标题", "D:/x/2026-09-25 01-00-00 主播 标题.ass": "2026-09-25 01-00-00 主播 标题", } for in, want := range cases { if got := danmuBaseName(in); got != want { t.Fatalf("danmuBaseName(%q)=%q,期望 %q", in, got, want) } } } // 文件名时间戳解析 func TestNameStartNs(t *testing.T) { want := time.Date(2026, 9, 25, 1, 44, 57, 0, time.Local).UnixNano() if got := nameStartNs("/a/b/2026-09-25 01-44-57 Xsphere 电脑自己画画.mp4"); got != want { t.Fatalf("nameStartNs=%d,期望 %d", got, want) } for _, bad := range []string{"abc.mp4", "2026-09-25.mp4", ""} { if got := nameStartNs(bad); got != 0 { t.Fatalf("%q 不该解析出时间,得到 %d", bad, got) } } } // 偏移推算:同名就是同一场,直接 0 func TestGuessOffsetSameName(t *testing.T) { dir := setupMergeTest(t) name := "2026-09-25 01-44-57 Xsphere 电脑自己画画" videos := []string{filepath.Join(dir, name+".mp4")} off, note := guessOffset(videos, filepath.Join(dir, name+".jsonl"), danmuMeta{}) if off != 0 { t.Fatalf("同名时偏移应为 0,实际 %v", off) } if !strings.Contains(note, "文件名一致") { t.Fatalf("说明文字不对:%q", note) } // .ass 也要能认出来同名 if off, _ := guessOffset(videos, filepath.Join(dir, name+".nick.ass"), danmuMeta{}); off != 0 { t.Fatalf("同名 .nick.ass 时偏移应为 0,实际 %v", off) } } // 偏移推算:文件名时间不同就按时间差算 func TestGuessOffsetDiffName(t *testing.T) { dir := setupMergeTest(t) videos := []string{filepath.Join(dir, "2026-09-25 02-00-00 主播 标题.mp4")} off, note := guessOffset(videos, filepath.Join(dir, "2026-09-25 01-44-57 主播 标题.jsonl"), danmuMeta{}) // 弹幕比视频早 15 分 3 秒开始 if off != -903 { t.Fatalf("偏移应为 -903,实际 %v", off) } if !strings.Contains(note, "偏移偏大") { t.Fatalf("偏移过大时应有提醒,实际 %q", note) } } // 路径解析:文件名、引号、绝对路径、目录、不存在 func TestResolveMergeInput(t *testing.T) { setupMergeTest(t) name := "2026-09-25 01-44-57 主播 标题.mp4" target := writeTestFile(t, filepath.Join(recordSaveDir(), name), "x") cases := []struct { in string want string }{ {name, target}, // 录播目录里的文件名 {`"` + name + `"`, target}, // 从资源管理器复制来会带引号 {" " + name + " ", target}, // 前后空格 {target, target}, // 绝对路径 {filepath.ToSlash(target), target}, // 正斜杠的绝对路径 } for _, c := range cases { got, err := resolveMergeInput(c.in) if err != nil { t.Fatalf("解析 %q 失败:%v", c.in, err) } if normalizePath(got) != normalizePath(c.want) { t.Fatalf("解析 %q 得到 %q,期望 %q", c.in, got, c.want) } } bad := []string{"", " ", ".", "..", "不存在的文件.mp4", "../外面.mp4", filepath.Join(recordSaveDir(), "没有这个.mp4")} for _, b := range bad { if got, err := resolveMergeInput(b); err == nil { t.Fatalf("%q 应该报错,却得到 %q", b, got) } } // 目录不能被当成文件 if _, err := resolveMergeInput(recordSaveDir()); err == nil { t.Fatal("目录应该被拒绝") } } // 输出文件名校验 func TestSanitizeOutName(t *testing.T) { good := map[string]string{"a.mp4": "a.mp4", "a": "a.mp4", "标题.mp4": "标题.mp4", "x.MP4": "x.MP4"} for in, want := range good { got, err := sanitizeOutName(in) if err != nil || got != want { t.Fatalf("sanitizeOutName(%q)=(%q,%v),期望 %q", in, got, err, want) } } bad := []string{"", " ", ".", "..", "a/b.mp4", `a\b.mp4`, "a\nb.mp4", "../x.mp4"} for _, b := range bad { if got, err := sanitizeOutName(b); err == nil { t.Fatalf("%q 应该被拒绝,却得到 %q", b, got) } } } // 输出不覆盖已有成片 func TestFreeOutPath(t *testing.T) { dir := setupMergeTest(t) base := "标题_弹幕合并" if got := filepath.Base(freeOutPath(mergeSaveDir(), base)); got != base+".mp4" { t.Fatalf("没有冲突时应返回原名,实际 %q", got) } writeTestFile(t, filepath.Join(mergeSaveDir(), base+".mp4"), "x") if got := filepath.Base(freeOutPath(mergeSaveDir(), base)); got != base+"(2).mp4" { t.Fatalf("有冲突时应加序号,实际 %q", got) } _ = dir } // 扩展名白名单:不是视频/弹幕的文件不能被当输入 func TestBuildMergePlanRejectsBadExt(t *testing.T) { dir := setupMergeTest(t) writeTestFile(t, filepath.Join(recordSaveDir(), "2026-09-25 01-00-00 主播 标题.jsonl"), `{"playResX":640,"playResY":360,"fontSize":24,"startTime":0}`+"\n") // 视频给个 .txt writeTestFile(t, filepath.Join(recordSaveDir(), "note.txt"), "x") _, err := buildMergePlan(mergeRequest{ Videos: []string{"note.txt"}, Danmu: "2026-09-25 01-00-00 主播 标题.jsonl", }) if err == nil || !strings.Contains(err.Error(), "不支持的视频格式") { t.Fatalf("应该拒绝 .txt 视频,实际 err=%v", err) } // 弹幕给个 .mp4 writeTestFile(t, filepath.Join(recordSaveDir(), "2026-09-25 01-00-00 主播 标题.mp4"), "x") _, err = buildMergePlan(mergeRequest{ Videos: []string{"2026-09-25 01-00-00 主播 标题.mp4"}, Danmu: "2026-09-25 01-00-00 主播 标题.mp4", }) if err == nil || !strings.Contains(err.Error(), "不支持的弹幕格式") { t.Fatalf("应该拒绝用 mp4 当弹幕,实际 err=%v", err) } // 什么都不填 if _, err := buildMergePlan(mergeRequest{}); err == nil { t.Fatal("空请求应该报错") } _ = dir } // 正在写入的文件不能被当输入,也不能被覆盖 func TestBuildMergePlanRejectsActiveFile(t *testing.T) { setupMergeTest(t) jsonl := "2026-09-25 01-00-00 主播 标题.jsonl" video := "2026-09-25 01-00-00 主播 标题.mp4" writeTestFile(t, filepath.Join(recordSaveDir(), jsonl), `{"playResX":640,"playResY":360,"fontSize":24,"startTime":0}`+"\n") writeTestFile(t, filepath.Join(recordSaveDir(), video), "x") markFileActive(filepath.Join(recordSaveDir(), video), true) defer markFileActive(filepath.Join(recordSaveDir(), video), false) _, err := buildMergePlan(mergeRequest{Videos: []string{video}, Danmu: jsonl}) if err == nil || !strings.Contains(err.Error(), "正在录制或写入中") { t.Fatalf("正在写入的视频应该被拒绝,实际 err=%v", err) } } // 录制侧和管理接口侧必须用同一种路径形式,否则「正在写入」的判断会失效, // 用户就能在 web UI 里删掉正在录制的文件(删不掉时会抛系统错误而不是给友好提示) func TestMarkAndCheckUseSamePathForm(t *testing.T) { dir := setupMergeTest(t) cwd, err := os.Getwd() if err != nil { t.Fatal(err) } check := func(tag string) { t.Helper() if !filepath.IsAbs(recordSaveDir()) || !filepath.IsAbs(mergeSaveDir()) { t.Fatalf("%s:保存目录没归一成绝对路径,record=%q merge=%q", tag, recordSaveDir(), mergeSaveDir()) } full, err := resolveFile("merge", "x.mp4") if err != nil { t.Fatal(err) } markFileActive(filepath.Join(mergeSaveDir(), "x.mp4"), true) defer markFileActive(filepath.Join(mergeSaveDir(), "x.mp4"), false) if !isFileActive(full) { t.Fatalf("%s:录制侧标记的文件,管理接口侧查不到(%q)", tag, full) } // 同一个文件的相对写法与绝对写法必须被判成同一个 // (相对路径按 cwd 解析,所以要从绝对路径反推才能指向同一个文件) abs := filepath.Join(mergeSaveDir(), "y.mp4") rel, err := filepath.Rel(cwd, abs) if err == nil && !filepath.IsAbs(rel) { markFileActive(rel, true) defer markFileActive(rel, false) if !isFileActive(abs) { t.Fatalf("%s:相对路径 %q 与绝对路径 %q 归一后对不上", tag, rel, abs) } } } // directory 是绝对路径的情况 check("directory 为绝对路径") // 模拟 -record . 这类相对路径的部署方式:用 cwd 到临时目录的相对路径, // 既能触发相对路径分支,又不会碰到仓库自己的 record/merged 目录 savedDir := config.Directory defer func() { config.Directory = savedDir }() oldRecord := *recordDir defer func() { *recordDir = oldRecord }() config.Directory = "" relDir, err := filepath.Rel(cwd, dir) if err != nil { // 跨盘符取不到相对路径时退回最朴素的 ".",一样是相对路径 relDir = "." } if filepath.IsAbs(relDir) { t.Fatalf("准备相对路径失败,得到的是绝对路径 %q", relDir) } *recordDir = relDir check("保存根目录为相对路径") } // ffmpeg 的路径必须归一成绝对路径:合并时 ffmpeg 的工作目录会被改成输出目录, // 相对路径留着的话它会去输出目录里找,最后报一个跟真实原因对不上的「找不到文件」 func TestGetFFmpegAbsolutizesRelativePath(t *testing.T) { ff := testFFmpeg(t) savedPath, savedExe := config.FFmpegPath, exeDir defer func() { config.FFmpegPath, exeDir = savedPath, savedExe }() same := func(tag, want, got string) { t.Helper() if !filepath.IsAbs(got) { t.Fatalf("%s:没归一成绝对路径,得到 %q", tag, got) } if !strings.EqualFold(filepath.Clean(got), filepath.Clean(want)) { t.Fatalf("%s:归一后指向的不是同一个文件,得到 %q,期望 %q", tag, got, want) } } // 常见写法一:只写可执行文件名,靠程序所在目录找到它 exeDir = filepath.Dir(ff) config.FFmpegPath = filepath.Base(ff) same("只写文件名", ff, getFFmpeg()) // 常见写法二:相对当前工作目录的路径 cwd, err := os.Getwd() if err != nil { t.Fatal(err) } rel, err := filepath.Rel(cwd, ff) if err != nil || filepath.IsAbs(rel) { t.Skipf("取不到相对当前目录的路径:%v", err) } exeDir = "" config.FFmpegPath = rel same("相对当前目录", ff, getFFmpeg()) } // 端到端:原版 .ass → 还原成 jsonl → 指定视频合并 → 出片 func TestManualMergeFromAssEndToEnd(t *testing.T) { dir := setupMergeTest(t) ffmpeg := getFFmpeg() if ffmpeg == "" { t.Skip("没有 ffmpeg") } name := "2026-09-25 01-00-00 测试主播 标题" // 6 秒黑底视频,弹幕在 1 / 3 / 5 秒出现 video := filepath.Join(recordSaveDir(), name+".mp4") cmd := exec.Command(ffmpeg, "-v", "error", "-y", "-f", "lavfi", "-i", "color=c=black:size=640x360:rate=25", "-t", "6", "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", video) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("生成测试视频失败:%v\n%s", err, out) } // 弹幕文件用原格式 .ass(模拟上游原版录出来的东西),字号 24 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,24,&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,0:00:01.00,0:00:11.00,Danmu,甲(101),20,20,2,,{\move(400,24,-40,24)}第一条弹幕 Dialogue: 0,0:00:03.00,0:00:13.00,Danmu,乙(202),20,20,2,,{\move(400,48,-40,48)}第二条弹幕 Dialogue: 0,0:00:05.00,0:00:15.00,Danmu,甲(101),20,20,2,,{\move(400,72,-40,72)}第三条弹幕 `, name) assPath := writeTestFile(t, filepath.Join(recordSaveDir(), name+".ass"), ass) // 还原成 jsonl src, err := prepareDanmuSource(assPath, true) if err != nil { t.Fatalf("还原弹幕失败:%v", err) } if src.count != 3 { t.Fatalf("应还原出 3 条弹幕,实际 %d", src.count) } wantJsonl := filepath.Join(recordSaveDir(), name+".jsonl") if normalizePath(src.jsonlPath) != normalizePath(wantJsonl) { t.Fatalf("jsonl 应写在 %q,实际 %q", wantJsonl, src.jsonlPath) } if _, err := os.Stat(wantJsonl); err != nil { t.Fatalf(".ass 还原出的 jsonl 没有落盘:%v", err) } sum, err := scanJsonlSummary(wantJsonl) if err != nil { t.Fatalf("读还原出来的 jsonl 失败:%v", err) } if sum.count != 3 || sum.meta.PlayResX != 640 || sum.meta.FontSize != 24 { t.Fatalf("还原结果不对:count=%d meta=%+v", sum.count, sum.meta) } // 还原出来的弹幕没有头像链接,合成时会按 uid 反查(测试里已替换成返回空) if sum.hasAvatar { t.Fatal("从 .ass 还原不该有头像链接") } // 走一遍检查 plan, err := buildMergePlan(mergeRequest{Videos: []string{video}, Danmu: assPath}) if err != nil { t.Fatalf("检查失败:%v", err) } if plan.info.Offset != 0 { t.Fatalf("同名文件偏移应为 0,实际 %v(%s)", plan.info.Offset, plan.info.OffsetNote) } if plan.info.DanmuCount != 3 { t.Fatalf("检查结果里的弹幕条数应为 3,实际 %d", plan.info.DanmuCount) } if plan.info.Duration < 5.9 || plan.info.Duration > 6.1 { t.Fatalf("视频时长应约 6 秒,实际 %v", plan.info.Duration) } if !strings.Contains(plan.info.OutName, "_弹幕合并.mp4") { t.Fatalf("默认输出名不对:%q", plan.info.OutName) } // 真正合并 if err := execManualMerge(plan); err != nil { t.Fatalf("手动合并失败:%v", err) } if _, err := os.Stat(plan.out); err != nil { t.Fatalf("没有生成成片:%v", err) } d := probeDuration(ffmpeg, plan.out) if d < 5.9 || d > 6.3 { t.Fatalf("成片时长应约 6 秒,实际 %v", d) } // 合并完不能再被标记成"写入中" if isFileActive(plan.out) { t.Fatal("合并结束后不该还被标记为写入中") } t.Logf("成片 %s(%.2f 秒)", filepath.Base(plan.out), d) _ = dir } // 多段:两个分段要能拼成一个再合成 func TestManualMergeMultiSegment(t *testing.T) { setupMergeTest(t) ffmpeg := getFFmpeg() if ffmpeg == "" { t.Skip("没有 ffmpeg") } mk := func(name string, sec float64) string { p := filepath.Join(recordSaveDir(), name) cmd := exec.Command(ffmpeg, "-v", "error", "-y", "-f", "lavfi", "-i", "color=c=black:size=640x360:rate=25", "-t", fmt.Sprintf("%.0f", sec), "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", p) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("生成测试视频失败:%v\n%s", err, out) } return p } // 第一段 01:00:00 开始,第二段 01:00:04 开始(模拟断流后重连) v1 := mk("2026-09-25 01-00-00 测试主播 标题.mp4", 4) v2 := mk("2026-09-25 01-00-04 测试主播 标题.mp4", 4) // 弹幕文件按第一段命名,弹幕时间覆盖两段 ass := `[Script Info] Title: 2026-09-25 01-00-00 测试主播 标题 PlayResX: 640 PlayResY: 360 [V4+ Styles] Format: Name, Fontname, Fontsize Style: Danmu,Microsoft YaHei,24 [Events] Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text Dialogue: 0,0:00:01.00,0:00:11.00,Danmu,甲(101),0,0,0,,{\move(400,24,-40,24)}第一段里的弹幕 Dialogue: 0,0:00:05.00,0:00:15.00,Danmu,乙(202),0,0,0,,{\move(400,48,-40,48)}第二段里的弹幕 ` assPath := writeTestFile(t, filepath.Join(recordSaveDir(), "2026-09-25 01-00-00 测试主播 标题.ass"), ass) plan, err := buildMergePlan(mergeRequest{Videos: []string{v2, v1}, Danmu: assPath}) // 故意填反顺序 if err != nil { t.Fatalf("检查失败:%v", err) } // 填反了也要按录制时间纠正过来 if filepath.Base(plan.videos[0]) != filepath.Base(v1) { t.Fatalf("分段顺序应纠正为第一段在前,实际 %q", filepath.Base(plan.videos[0])) } if err := execManualMerge(plan); err != nil { t.Fatalf("多段手动合并失败:%v", err) } d := probeDuration(ffmpeg, plan.out) // 两段各 4 秒,拼起来应该约 8 秒 if d < 7.5 || d > 8.5 { t.Fatalf("拼接后的成片应约 8 秒,实际 %.2f", d) } t.Logf("多段成片 %s(%.2f 秒)", filepath.Base(plan.out), d) } // 手动合并:按视频文件名自动找同目录下的同名弹幕文件 func TestFindDanmuForVideo(t *testing.T) { savedConfig := config oldRecordDir := recordDir savedMerge := config.MergeOutputDir defer func() { config = savedConfig recordDir = oldRecordDir config.MergeOutputDir = savedMerge }() // 每个用例一套独立的目录环境 prepare := func(t *testing.T) (string, string) { t.Helper() dir := t.TempDir() config.Directory = dir config.RecordSubdir = defaultRecordSubdir config.MergeSubdir = defaultMergeSubdir config.MergeOutputDir = "" if recordDir == nil { recordDir = new(string) } *recordDir = dir recDir := recordSaveDir() if err := os.MkdirAll(recDir, 0755); err != nil { t.Fatal(err) } return dir, recDir } t.Run("优先用 jsonl", func(t *testing.T) { _, recDir := prepare(t) writeTestFile(t, filepath.Join(recDir, "a.mp4"), "v") writeTestFile(t, filepath.Join(recDir, "a.ass"), "ass") writeTestFile(t, filepath.Join(recDir, "a.jsonl"), "jsonl") // 录播目录里的只填文件名,方便以后改保存目录 if got := findDanmuForVideo("a.mp4"); got != filepath.Join(recDir, "a.jsonl") { t.Errorf("应优先 .jsonl,实际 %s", got) } if got := mergeFillValue(filepath.Join(recDir, "a.jsonl")); got != "a.jsonl" { t.Errorf("录播目录里的应只写文件名,实际 %s", got) } }) t.Run("没有 jsonl 时用 ass", func(t *testing.T) { _, recDir := prepare(t) writeTestFile(t, filepath.Join(recDir, "b.mp4"), "v") writeTestFile(t, filepath.Join(recDir, "b.ass"), "ass") if got := findDanmuForVideo("b.mp4"); got != filepath.Join(recDir, "b.ass") { t.Errorf("应回退到 .ass,实际 %s", got) } }) t.Run("只有昵称头像版 ass 也能认", func(t *testing.T) { _, recDir := prepare(t) writeTestFile(t, filepath.Join(recDir, "c.mp4"), "v") writeTestFile(t, filepath.Join(recDir, "c.nick.ass"), "ass") if got := findDanmuForVideo("c.mp4"); got != filepath.Join(recDir, "c.nick.ass") { t.Errorf("应认 .nick.ass,实际 %s", got) } }) t.Run("视频在别处时用同目录的弹幕并填绝对路径", func(t *testing.T) { _, _ = prepare(t) other := t.TempDir() video := filepath.Join(other, "d.mp4") writeTestFile(t, video, "v") writeTestFile(t, filepath.Join(other, "d.jsonl"), "jsonl") got := findDanmuForVideo(video) if got != filepath.Join(other, "d.jsonl") { t.Errorf("应找到同目录的弹幕,实际 %s", got) } if fill := mergeFillValue(got); fill != got { t.Errorf("不在录播/成片目录时应填绝对路径,实际 %s", fill) } }) t.Run("同目录没有就看录播目录", func(t *testing.T) { _, recDir := prepare(t) writeTestFile(t, filepath.Join(recDir, "e.jsonl"), "jsonl") other := t.TempDir() video := filepath.Join(other, "e.mp4") writeTestFile(t, video, "v") got := findDanmuForVideo(video) if got != filepath.Join(recDir, "e.jsonl") { t.Errorf("应回退到录播目录,实际 %s", got) } if fill := mergeFillValue(got); fill != "e.jsonl" { t.Errorf("录播目录里的应只写文件名,实际 %s", fill) } }) t.Run("找不到就返回空", func(t *testing.T) { _, recDir := prepare(t) writeTestFile(t, filepath.Join(recDir, "f.mp4"), "v") if got := findDanmuForVideo("f.mp4"); got != "" { t.Errorf("没有同名弹幕时应返回空,实际 %s", got) } if got := findDanmuForVideo("不存在的视频.mp4"); got != "" { t.Errorf("视频本身找不到时应返回空,实际 %s", got) } }) t.Run("成片目录里的也写文件名", func(t *testing.T) { dir, _ := prepare(t) mergeDir := mergeSaveDir() if err := os.MkdirAll(mergeDir, 0755); err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(mergeDir, "g.jsonl"), "jsonl") if fill := mergeFillValue(filepath.Join(dir, config.MergeSubdir, "g.jsonl")); fill != "g.jsonl" { t.Errorf("成片目录里的应只写文件名,实际 %s", fill) } }) }