Files
acfunlive-remake/manualmerge_test.go
T
2026-09-25 13:16:54 +08:00

721 lines
25 KiB
Go
Raw 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 (
"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,不写死某台机器上的路径
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 := runManualMerge(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 := runManualMerge(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)
}