一、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 报错(分组 / 拆组 / 逐任务参数 / 转码行渲染 /
跳过无视频轨文件 / 估算失败提示 / 结果徽标)
971 lines
34 KiB
Go
971 lines
34 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|