Files
acfunlive-remake/manualmerge_test.go
T
tamakyi a409c5b4dd 新增「转码」分区与手动合并逐任务配置,修掉 PC 端图表文字被拉扁
一、PC 端图表文字被拉伸(bug)
.spark 折线图把 viewBox 写死成 "0 0 300 48" 又带 preserveAspectRatio="none":
移动端元素宽≈300px 时缩放≈1 看不出问题,PC 上卡片宽约 1030px,横轴被放大 3.4 倍
而纵轴仍是 48px,图里的 <text> 被整体拉扁。现在按元素实际宽度算 viewBox 宽度
(取不到时回落 300),并监听 resize 重画;preserveAspectRatio 保留不影响 1:1 显示。

二、新增「转码」分区(transcode.go,第七个 tab)
原来只有「录播 + 弹幕 → 成片」一条路,已有成片想压小、换 HEVC、降分辨率只能手搓命令行。
现在从文件列表多选加入,每个文件一行独立配置:编码器、码率控制(跟随源片 / 目标码率 /
质量 CRF·ICQ / 源片百分比)、编码速度、输出帧率、分辨率(只列不高于源片的档)、
音频(拷贝 / 128k / 192k / 去掉),并实时预估产物大小与整批合计。
 - POST /api/transcode/probe | estimate | transcode,都在既有的口令鉴权之后
 - 目标码率模式按「码率 × 时长」出准数;质量模式用单调系数表插值并标注是粗估
 - 落盘沿用合并那套:先在源文件所在目录生成,完成后再 placeOutput 到输出目录,
   失败或中断删临时文件,不留半成品
 - 与合并共用同一张进度卡片和任务位(mergeSlot),串行排队,可暂停 / 停止

三、手动合并改成「一次排多个任务」
原来一次只能合一场。现在多选视频后按「文件名里的录制时间 + 主体名」自动归组
(相邻两段间隔 2 小时内算同一场),每组一个任务卡,各自配弹幕、偏移、输出名和
合成参数(码率 / 帧率 / 字号 / 编码器),可拆组、可移除;服务端串行跑,逐个回传结果。
请求体从 {videos,danmu,...} 扩成 {jobs:[{...}]},老的单任务写法继续可用。

四、审阅中一并修掉 / 加固的点
 - 容器总码率含音频,直接当视频码率用会错两处:预估体积时又加一遍音轨(算成双份),
   「跟随源片」时把音轨码率也编进视频里。现在分开记「视频流码率」与「容器总码率」,
   转码侧按「视频流码率 → 容器码率 − 音轨 → 体积 ÷ 时长 − 音轨」三级取值;
   合并的自动码率仍按老口径(容器含音频),单独用 streamOrContainerKbps 锁住
 - 源片本来没有音轨时,预估不再凭空加 128 kbps 音频
 - 目标码率加上界(200 Mbps),单位写错的数在开跑前就被挡住(合并的逐任务码率同理)
 - 探测结果加缓存(ffmpeg + 路径 + 体积 + 修改时间),体积估算跟着输入框重发时不再
   每次都起一个 ffmpeg;文件一变(录制中追加、换了文件)缓存自然失效
 - 只有音轨 / 只有封面图的文件、以及正在写入的文件,排队时就被挡住并说清原因
 - probeMedia / parseMediaInfo 合并了原先重复的两套解析(probeDuration 与
   probeVideoStream 各起一次 ffmpeg 解析同一段输出),burnDanmu 现在一次探测拿全
 - 前端:估算参数非法时把服务端给的原因显示在合计行(原来静默不动,像是没改成功);
   估算请求加序号,慢的旧回包不再覆盖新结果;转码下拉刷新时保留已勾选项;
   跑完的行标「完成 / 失败」并淡化;删掉没有对应元素、从未使用的 CSS 规则

验证
- gofmt / go vet 干净;go build(windows)与 GOOS=linux go build 均通过
- go test ./... 20.9s 全过;go test -race ./... 26.1s 全过(182 → 206 个用例,
  transcode_test.go 新增 20 个)
- 反向验证 C:\OtherFiles\acf-test\reverse_check.py 从 37 项加到 43 项,全过
- 51899 隔离实例 + 真 ffmpeg 冒烟 29 项全过:401 鉴权、probe 的 hasVideo/hasAudio、
  ts 容器(不带每流码率)探测到的视频码率 = 容器 429 − 音轨 127、
  HEVC 按 0.65 折算、参数非法 400 带原因、只有音轨的文件排队即被拒、
  输出目录不存在被拒、两任务队列真转成功并落到指定目录、缩放成片高度确实是 240、
  日志里带「第 n/N 个」和参数摘要
- 页面 jsdom 冒烟 37 项全过、无 JS 报错(分组 / 拆组 / 逐任务参数 / 转码行渲染 /
  跳过无视频轨文件 / 估算失败提示 / 结果徽标)
2026-09-27 10:36:29 +08:00

971 lines
34 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,不写死某台机器上的路径
// 按真实链路跑一次手动合并:独占任务位,跑完一定要收尾。
// 不能直接调 runManualMerge —— 上一个用例如果没收尾,jobStart 会拒掉这一场
// (正是它的职责),测试就会莫名其妙失败
func execManualMerge(plan mergePlan) error {
jobFinish("")
err := runMergeExclusive(func() error { return runManualMerge(plan, 1, 1) })
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)
}
})
}
// ==================== 一次排多个合并任务 ====================
// 造一组能过检查的「视频 + 弹幕」:视频随便写点字节,弹幕用真能解析的 jsonl
// 文件要落在录播目录里,因为请求里只带文件名,resolveMergeInput 按录播目录找
func writeMergePair(t *testing.T, name string) (string, string) {
t.Helper()
video := filepath.Join(recordSaveDir(), name+".mp4")
writeTestFile(t, video, "video")
jsonl := filepath.Join(recordSaveDir(), name+".jsonl")
writeTestFile(t, jsonl,
"{\"playResX\":1920,\"playResY\":1080,\"fontSize\":48,\"startTime\":0}\n"+
"{\"uid\":101,\"content\":\"第一条\",\"time\":1,\"lane\":0}\n")
return filepath.Base(video), filepath.Base(jsonl)
}
// 批量请求要能拆成多个方案,且每个方案各自带上自己的参数
func TestBuildMergePlansBatch(t *testing.T) {
setupMergeTest(t)
v1, d1 := writeMergePair(t, "2026-09-25 01-00-00 甲 第一场")
v2, d2 := writeMergePair(t, "2026-09-25 02-00-00 乙 第二场")
br1, fps2 := 1500, 60
plans, err := buildMergePlans(mergeRequest{Jobs: []mergeJobReq{
{Videos: []string{v1}, Danmu: d1, Options: mergeOptions{Bitrate: &br1}},
{Videos: []string{v2}, Danmu: d2, Options: mergeOptions{Fps: &fps2}},
}})
if err != nil {
t.Fatalf("批量检查失败:%v", err)
}
if len(plans) != 2 {
t.Fatalf("应拆成 2 个方案,实际 %d", len(plans))
}
if plans[0].opts.bitrate() != 1500 {
t.Errorf("第一个任务的码率没带上:%d", plans[0].opts.bitrate())
}
if plans[1].opts.bitrate() != config.MergeBitrate {
t.Errorf("第二个任务没填码率就该用全局设置,实际 %d", plans[1].opts.bitrate())
}
if plans[1].opts.fps() != 60 {
t.Errorf("第二个任务的帧率没带上:%d", plans[1].opts.fps())
}
// 输出名各自独立
if plans[0].info.OutName == plans[1].info.OutName {
t.Errorf("两个任务的输出名撞了:%s", plans[0].info.OutName)
}
// 单个任务(老写法)仍然只出一个方案
one, err := buildMergePlans(mergeRequest{Videos: []string{v1}, Danmu: d1})
if err != nil || len(one) != 1 {
t.Fatalf("单个任务应只出 1 个方案,实际 %d(err=%v)", len(one), err)
}
}
// 排在后面的任务有问题时要连序号一起报出来,并且一个都不该开始
func TestBuildMergePlansReportsBadIndex(t *testing.T) {
setupMergeTest(t)
v1, d1 := writeMergePair(t, "2026-09-25 01-00-00 甲 第一场")
_, err := buildMergePlans(mergeRequest{Jobs: []mergeJobReq{
{Videos: []string{v1}, Danmu: d1},
{Videos: []string{v1}, Danmu: "不存在的弹幕.jsonl"},
}})
if err == nil {
t.Fatal("第二个任务的文件不存在,应当报错")
}
if !strings.Contains(err.Error(), "第 2 个任务") {
t.Errorf("报错里该带上序号,实际:%v", err)
}
}
// 一次排太多要挡住,别让人不小心把几十场全排上
func TestBuildMergePlansCapsCount(t *testing.T) {
setupMergeTest(t)
v1, d1 := writeMergePair(t, "2026-09-25 01-00-00 甲 第一场")
jobs := make([]mergeJobReq, 0, maxMergeJobs+1)
for i := 0; i < maxMergeJobs+1; i++ {
jobs = append(jobs, mergeJobReq{Videos: []string{v1}, Danmu: d1})
}
if _, err := buildMergePlans(mergeRequest{Jobs: jobs}); err == nil {
t.Errorf("超过 %d 个任务应当被挡住", maxMergeJobs)
}
}
// 逐任务参数的兜底和校验
func TestMergeOptionsFallbackAndValidate(t *testing.T) {
ff := testFFmpeg(t)
config.MergeBitrate, config.MergeFps, config.DanmuFontSize = 2000, 30, 42
defer func() { config.MergeBitrate, config.MergeFps, config.DanmuFontSize = 0, 0, 0 }()
var empty mergeOptions
if empty.bitrate() != 2000 || empty.fps() != 30 || empty.fontSize() != 42 {
t.Errorf("没填的项应回落到全局设置,实际 %d / %d / %d",
empty.bitrate(), empty.fps(), empty.fontSize())
}
// 填 0 是有明确含义的,不能被当成「没填」
zero := 0
if (mergeOptions{Bitrate: &zero}).bitrate() != 0 {
t.Error("填了 0 就应当是 0(自动码率),不是全局设置的值")
}
if msg := (mergeOptions{Fps: &zero}).validate(ff); msg != "" {
t.Errorf("帧率 0(跟随源片)不该被挡:%s", msg)
}
if msg := (mergeOptions{Encoder: "auto"}).validate(ff); msg != "" {
t.Errorf("auto 表示自动挑,不该被挡:%s", msg)
}
bad := -1
if msg := (mergeOptions{Bitrate: &bad}).validate(ff); msg == "" {
t.Error("负的码率应当被挡住")
}
over := maxMergeFps + 1
if msg := (mergeOptions{Fps: &over}).validate(ff); msg == "" {
t.Error("超出上限的帧率应当被挡住")
}
if msg := (mergeOptions{Encoder: "av1_nvenc"}).validate(ff); msg == "" {
t.Error("本机没有的编码器应当被挡住")
}
// 本机可用的编码器要放行
for _, e := range availableEncoders(ff) {
if msg := (mergeOptions{Encoder: e}).validate(ff); msg != "" {
t.Errorf("本机可用的 %s 不该被挡:%s", e, msg)
}
}
}