一、合并收尾只删真正进了成片的分段 prepareForMerge 中途有 4 处会回退成「只合并第一个分段」,而收尾仍按原分段列表 全删源视频 —— 后半场没进成片却照样被删掉,等于永久丢数据。现在 prepareForMerge 把「实际参与合并的源文件」一并返回(新增 used),收尾只删这些。 二、拼接中断不再在录播目录留几 GB 中间文件 concat 失败分支原来只有「被停止」那条路径删了 acfunlive_chunk_joined.*, 普通失败会把它留在录播目录,而且带着 chunkTempPrefix,界面文件列表里根本看不见。 三、把「被停止」和「拼接失败」分开 停止任务会杀掉 ffmpeg 让它非 0 退出,原来一律报成「拼接失败」, 卡片上显示的是失败而不是已停止;prepareForMerge 那里也统一改用 errors.Is 判断。 四、单块弹幕条数加硬上限(maxDanmuPerChunkHard = 200) 块尾会把「显示时间跨过块尾」的弹幕一并并入,弹幕密集时单块能膨胀到几百条。 一条弹幕在 -filter_complex 里约占 110 字节,几百条足以顶破 Windows 的 32767 字符命令行上限,表现同样是「合并失败且看不出原因」。到上限就把块尾 收到下一条弹幕出现之前,保证被挤到下一块的弹幕不会因为「整条早于块头」被丢弃。 五、确定性失败不再白重试三次 头像超出体积/尺寸上限这类失败重试多少次结果都一样,原来会白白把十几 MB 重新下三遍。 新增 noRetryError(runThrice 用 errors.As 识别)只标记这两个上限,网络类错误照旧重试。 六、ffmpeg 路径统一归一成绝对路径 PATH 里带「.」这类相对条目、或程序目录探测失败时,getFFmpeg 会返回相对路径。 合并时 ffmpeg 工作目录被改成输出目录,相对路径到那边就找不着了;而且 Windows 上 exec 会直接拒绝执行「相对当前目录」找到的可执行文件,报错还跟路径完全看不出关系。 验证: - gofmt -l 空;go vet ./... 空;go build ./... 与 GOOS=linux go build 通过 - go test ./... 全绿(10.8s);go test -race ./... 全绿(14.7s) - 新增 chunks_test.go 10 个用例;6 项修复逐条反向验证(临时退回修复 → 对应测试确实失败) - 51897 隔离实例上新产物实测:e2e.py 24 项全过;smoke_extra.py 34 项全过 (含并发合并返回 409 并报出在跑的任务名、口令失败后正确口令仍立刻可用、 超大头像被拒不误伤正常头像且只尝试 1 次、停止后无临时文件残留)
296 lines
9.6 KiB
Go
296 lines
9.6 KiB
Go
package main
|
||
|
||
import (
|
||
"errors"
|
||
"math"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// 密集弹幕:每 0.02 秒一条,10 秒停留窗口里能塞进 500 条。
|
||
// 块尾扩张原来没有上限,会把窗口里的弹幕几乎全并进同一块
|
||
func denseRecords(n int) []danmuRecord {
|
||
records := make([]danmuRecord, 0, n)
|
||
for i := 0; i < n; i++ {
|
||
records = append(records, danmuRecord{
|
||
Time: float64(i) * 0.02,
|
||
UID: int64(i%5 + 1),
|
||
Name: "密集测试",
|
||
Content: "弹幕内容",
|
||
Lane: i % 10,
|
||
})
|
||
}
|
||
return records
|
||
}
|
||
|
||
// 单块条数必须被硬上限挡住,否则 -filter_complex 会撑爆命令行长度
|
||
func TestSplitDanmuChunksCapsBlockSize(t *testing.T) {
|
||
records := denseRecords(600)
|
||
chunks := splitDanmuChunks(records, 0, 0)
|
||
if len(chunks) < 2 {
|
||
t.Fatalf("这么多弹幕应当分成多块,实际 %d 块", len(chunks))
|
||
}
|
||
total := 0
|
||
for i, ch := range chunks {
|
||
if n := len(ch.records); n > maxDanmuPerChunkHard {
|
||
t.Fatalf("第 %d 块有 %d 条弹幕,超过硬上限 %d", i+1, n, maxDanmuPerChunkHard)
|
||
}
|
||
total += len(ch.records)
|
||
}
|
||
if total != len(records) {
|
||
t.Fatalf("分块把弹幕弄丢或弄重了:%d -> %d", len(records), total)
|
||
}
|
||
}
|
||
|
||
// 硬上限会把一部分弹幕挤到下一块,这些弹幕在下一块里仍要从自己的时间点开始显示:
|
||
// 块尾不收紧的话,下一块的块头会落在它们后面,开头就被吃掉了
|
||
func TestSplitDanmuChunksLosesNoDanmu(t *testing.T) {
|
||
records := denseRecords(600)
|
||
chunks := splitDanmuChunks(records, 0, 0)
|
||
|
||
for i := 1; i < len(chunks); i++ {
|
||
if chunks[i].start != chunks[i-1].end {
|
||
t.Fatalf("第 %d 块和第 %d 块之间有空隙:%.3f -> %.3f",
|
||
i, i+1, chunks[i-1].end, chunks[i].start)
|
||
}
|
||
}
|
||
|
||
// 按 writeChunkAss 的可见区间算法检查:每条弹幕在它所在的那一块里都要有完整的显示区间
|
||
for _, ch := range chunks {
|
||
for _, r := range ch.records {
|
||
if vs := math.Max(r.Time, ch.start); vs > r.Time+1e-9 {
|
||
t.Fatalf("时间 %.3f 的弹幕在所在块里从 %.3f 才开始,开头被块头截掉了", r.Time, vs)
|
||
}
|
||
if ve := math.Min(r.Time+danmuShowSeconds, ch.end); ve <= r.Time+1e-9 {
|
||
t.Fatalf("时间 %.3f 的弹幕在所有块里都没有显示区间,会被整条丢掉", r.Time)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 回退成「只合并第一个分段」时,used 必须只剩第一段:
|
||
// 自动合并收尾按 used 删源视频,返回全部就等于把没进成片的后半场删掉
|
||
func TestPrepareForMergeOnlyMarksUsedSegments(t *testing.T) {
|
||
dir := t.TempDir()
|
||
a := filepath.Join(dir, "a.mp4")
|
||
b := filepath.Join(dir, "b.mp4")
|
||
for _, p := range []string{a, b} {
|
||
if err := os.WriteFile(p, []byte("x"), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
jsonl := filepath.Join(dir, "a.jsonl")
|
||
if err := os.WriteFile(jsonl, []byte("{}\n"), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// 用一个不存在的 ffmpeg:probeDuration 拿不到时长,触发「只合并第一个分段」的回退
|
||
segs := []recordSegment{{file: a, startNs: 0}, {file: b, startNs: int64(time.Second)}}
|
||
video, _, _, used, cleanup, err := prepareForMerge(filepath.Join(dir, "没有这个-ffmpeg.exe"), segs, jsonl, 0)
|
||
defer cleanup()
|
||
if err != nil {
|
||
t.Fatalf("回退不该报错:%v", err)
|
||
}
|
||
if video != a {
|
||
t.Fatalf("回退后应当合并第一段,实际 %s", video)
|
||
}
|
||
if len(used) != 1 || used[0] != a {
|
||
t.Fatalf("回退成只合并第一段时 used 应只含第一段,实际 %v", used)
|
||
}
|
||
}
|
||
|
||
// 直接改全局任务状态:测试里只碰 stopped 这一个字段,用完恢复
|
||
func setJobStopped(t *testing.T, v bool) {
|
||
t.Helper()
|
||
job.Lock()
|
||
prev := job.stopped
|
||
job.stopped = v
|
||
job.Unlock()
|
||
t.Cleanup(func() {
|
||
job.Lock()
|
||
job.stopped = prev
|
||
job.Unlock()
|
||
})
|
||
}
|
||
|
||
// 现场生成一个 1 秒的小视频分段
|
||
func makeTinyVideo(t *testing.T, ffmpegFile, path, color string) {
|
||
t.Helper()
|
||
if err := runFFmpegWith(ffmpegFile, []string{
|
||
"-y", "-f", "lavfi", "-i", "color=c=" + color + ":s=160x120:d=1:r=10",
|
||
"-c:v", "libx264", "-pix_fmt", "yuv420p", path,
|
||
}, filepath.Dir(path), nil); err != nil {
|
||
t.Skipf("生成测试分段失败,跳过:%v", err)
|
||
}
|
||
}
|
||
|
||
// 多段都拿到时长、成功拼接时,used 应当是全部分段(原来的删除行为不变)
|
||
func TestPrepareForMergeMarksAllSegmentsWhenJoined(t *testing.T) {
|
||
ffmpegFile := getFFmpeg()
|
||
if ffmpegFile == "" {
|
||
t.Skip("找不到 ffmpeg,跳过")
|
||
}
|
||
setJobStopped(t, false)
|
||
dir := t.TempDir()
|
||
a, b := filepath.Join(dir, "a.mp4"), filepath.Join(dir, "b.mp4")
|
||
makeTinyVideo(t, ffmpegFile, a, "red")
|
||
makeTinyVideo(t, ffmpegFile, b, "blue")
|
||
jsonl := filepath.Join(dir, "a.jsonl")
|
||
if err := os.WriteFile(jsonl, []byte(`{"playResX": 160, "playResY": 120, "fontSize": 20, "startTime": 0}`+"\n"+
|
||
`{"t": 0.2, "uid": 1, "name": "n", "avatar": "", "content": "c", "lane": 0, "width": 0}`+"\n"), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
segs := []recordSegment{{file: a, startNs: 0}, {file: b, startNs: int64(time.Second)}}
|
||
video, out, _, used, cleanup, err := prepareForMerge(ffmpegFile, segs, jsonl, 0)
|
||
defer cleanup()
|
||
if err != nil {
|
||
t.Fatalf("拼接两个正常分段不该报错:%v", err)
|
||
}
|
||
if video != filepath.Join(dir, chunkTempPrefix+"joined.mp4") {
|
||
t.Fatalf("应当用拼接后的文件合并,实际 %s", video)
|
||
}
|
||
if len(used) != 2 {
|
||
t.Fatalf("拼接成功时 used 应包含全部分段,实际 %v", used)
|
||
}
|
||
if _, err := os.Stat(out); err != nil {
|
||
t.Fatalf("重映射的弹幕文件应当存在:%v", err)
|
||
}
|
||
// cleanup 必须把中间文件收干净
|
||
cleanup()
|
||
if _, err := os.Stat(video); err == nil {
|
||
t.Fatalf("清理后拼接出来的中间视频还在:%s", video)
|
||
}
|
||
if _, err := os.Stat(out); err == nil {
|
||
t.Fatalf("清理后重映射的弹幕文件还在:%s", out)
|
||
}
|
||
}
|
||
|
||
// 拼接中途被停止:要报成 errJobStopped,并且把拼到一半的中间文件删掉
|
||
// (留在录播目录里既占几 GB 又带 chunkTempPrefix,界面文件列表里根本看不见)
|
||
func TestPrepareForMergeDropsJoinedWhenStopped(t *testing.T) {
|
||
ffmpegFile := getFFmpeg()
|
||
if ffmpegFile == "" {
|
||
t.Skip("找不到 ffmpeg,跳过")
|
||
}
|
||
setJobStopped(t, false)
|
||
dir := t.TempDir()
|
||
a, b := filepath.Join(dir, "a.mp4"), filepath.Join(dir, "b.mp4")
|
||
makeTinyVideo(t, ffmpegFile, a, "red")
|
||
makeTinyVideo(t, ffmpegFile, b, "blue")
|
||
jsonl := filepath.Join(dir, "a.jsonl")
|
||
if err := os.WriteFile(jsonl, []byte("{}\n"), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
joined := filepath.Join(dir, chunkTempPrefix+"joined.mp4")
|
||
if err := os.WriteFile(joined, []byte("拼到一半的内容"), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
setJobStopped(t, true)
|
||
|
||
segs := []recordSegment{{file: a, startNs: 0}, {file: b, startNs: int64(time.Second)}}
|
||
_, _, _, used, cleanup, err := prepareForMerge(ffmpegFile, segs, jsonl, 0)
|
||
defer cleanup()
|
||
if !errors.Is(err, errJobStopped) {
|
||
t.Fatalf("被停止时应当返回 errJobStopped,实际 %v", err)
|
||
}
|
||
if len(used) != 0 {
|
||
t.Fatalf("被停止时不该把任何分段算作已合并,实际 %v", used)
|
||
}
|
||
if _, err := os.Stat(joined); err == nil {
|
||
t.Fatalf("停止后拼到一半的中间文件应当被删掉:%s", joined)
|
||
}
|
||
}
|
||
|
||
// 任务处于「已请求停止」时,拼接失败要报成 errJobStopped,而不是「拼接失败」
|
||
func TestConcatChunksReportsStopped(t *testing.T) {
|
||
dir := t.TempDir()
|
||
f := filepath.Join(dir, "in.mp4")
|
||
if err := os.WriteFile(f, []byte("x"), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
out := filepath.Join(dir, "out.mp4")
|
||
|
||
setJobStopped(t, true)
|
||
|
||
err := concatChunks(filepath.Join(dir, "没有这个-ffmpeg.exe"), []string{f}, out, dir, nil)
|
||
if !errors.Is(err, errJobStopped) {
|
||
t.Fatalf("停止状态下拼接失败应当返回 errJobStopped,实际 %v", err)
|
||
}
|
||
}
|
||
|
||
// 没有停止请求时,拼接失败就是普通错误,别报成「已停止」
|
||
func TestConcatChunksReportsFailureWhenNotStopped(t *testing.T) {
|
||
dir := t.TempDir()
|
||
f := filepath.Join(dir, "in.mp4")
|
||
if err := os.WriteFile(f, []byte("x"), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
out := filepath.Join(dir, "out.mp4")
|
||
|
||
setJobStopped(t, false)
|
||
|
||
err := concatChunks(filepath.Join(dir, "没有这个-ffmpeg.exe"), []string{f}, out, dir, nil)
|
||
if err == nil {
|
||
t.Fatal("拼接应当失败")
|
||
}
|
||
if errors.Is(err, errJobStopped) {
|
||
t.Fatalf("没有停止请求时不该报成已停止:%v", err)
|
||
}
|
||
if !strings.Contains(err.Error(), "拼接") {
|
||
t.Fatalf("错误信息应当说明是拼接失败,实际 %v", err)
|
||
}
|
||
}
|
||
|
||
// 确定性失败不该白白重试三次
|
||
func TestRunThriceSkipsNoRetry(t *testing.T) {
|
||
calls := 0
|
||
err := runThrice(func() error {
|
||
calls++
|
||
return noRetry(errors.New("确定性失败"))
|
||
})
|
||
if calls != 1 {
|
||
t.Fatalf("标记为不重试的错误应当只尝试一次,实际 %d 次", calls)
|
||
}
|
||
if err == nil || !strings.Contains(err.Error(), "确定性失败") {
|
||
t.Fatalf("错误应当原样返回,实际 %v", err)
|
||
}
|
||
if strings.Contains(err.Error(), "运行三次") {
|
||
t.Fatalf("不该被包装成「运行三次都出现错误」:%v", err)
|
||
}
|
||
}
|
||
|
||
// 普通错误仍然照旧重试
|
||
func TestRunThriceStillRetriesNormalError(t *testing.T) {
|
||
calls := 0
|
||
err := runThrice(func() error {
|
||
calls++
|
||
if calls == 2 {
|
||
return nil
|
||
}
|
||
return errors.New("暂时失败")
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("第二次成功时不该报错:%v", err)
|
||
}
|
||
if calls != 2 {
|
||
t.Fatalf("普通错误应当重试,实际只尝试了 %d 次", calls)
|
||
}
|
||
}
|
||
|
||
// 交给 exec 的 ffmpeg 路径必须是绝对路径:
|
||
// Windows 上 exec 会直接拒绝执行「相对当前目录」找到的可执行文件
|
||
func TestGetFFmpegReturnsAbsPath(t *testing.T) {
|
||
p := getFFmpeg()
|
||
if p == "" {
|
||
t.Skip("本机找不到 ffmpeg,跳过")
|
||
}
|
||
if !filepath.IsAbs(p) {
|
||
t.Fatalf("getFFmpeg 应当返回绝对路径,实际 %q", p)
|
||
}
|
||
}
|