修第二批 P1:合并收尾、分块上限、确定性失败与 ffmpeg 路径归一

一、合并收尾只删真正进了成片的分段
   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 次、停止后无临时文件残留)
This commit is contained in:
tamakyi
2026-09-26 09:35:51 +08:00
parent 9b9b3fa2cd
commit 8e82aa54c6
6 changed files with 371 additions and 22 deletions
+17 -15
View File
@@ -86,8 +86,10 @@ func findMovedFile(path string) string {
}
// 录播因为断流可能分成多段:单段直接使用,多段先拼成完整视频并把弹幕时间映射到拼接后的时间轴。
// 返回合并要用的视频、字幕文件和弹幕相对视频起点的时间偏移,以及清理临时文件的函数。
func prepareForMerge(ffmpegFile string, segs []recordSegment, jsonlFile string, danmuStartNs int64) (string, string, float64, func(), error) {
// 返回合并要用的视频、字幕文件和弹幕相对视频起点的时间偏移、**实际参与合并的源文件**,
// 以及清理临时文件的函数。used 是给「合并完删源视频」用的:中途回退成「只合并第一个分段」时,
// 后面的分段并没有进成片,按原分段列表全删就等于把后半场永久丢了。
func prepareForMerge(ffmpegFile string, segs []recordSegment, jsonlFile string, danmuStartNs int64) (string, string, float64, []string, func(), error) {
noop := func() {}
// 录制结束时文件可能已经被移动到保存目录,逐个找实际位置
@@ -100,11 +102,11 @@ func prepareForMerge(ffmpegFile string, segs []recordSegment, jsonlFile string,
}
}
if len(files) == 0 {
return "", "", 0, noop, errors.New("找不到录播视频文件")
return "", "", 0, nil, noop, errors.New("找不到录播视频文件")
}
if len(files) == 1 {
offset := float64(danmuStartNs-starts[0]) / 1e9
return files[0], jsonlFile, offset, noop, nil
return files[0], jsonlFile, offset, files, noop, nil
}
lPrintf("本场录播中途中断过,共 %d 个分段,先拼接成完整视频再合并", len(files))
@@ -116,7 +118,7 @@ func prepareForMerge(ffmpegFile string, segs []recordSegment, jsonlFile string,
if durs[i] <= 0 {
lPrintWarnf("无法获取分段 %s 的时长,本场只合并第一个分段(后半场没有弹幕)", filepath.Base(f))
offset := float64(danmuStartNs-starts[0]) / 1e9
return files[0], jsonlFile, offset, noop, nil
return files[0], jsonlFile, offset, files[:1], noop, nil
}
}
@@ -136,13 +138,13 @@ func prepareForMerge(ffmpegFile string, segs []recordSegment, jsonlFile string,
}); err != nil {
// 任务被手动停止时不要再退化成「只合并第一个分段」,
// 也别把拼到一半的中间文件留下
if jobStopRequested() {
_ = os.Remove(joined)
return "", "", 0, noop, errJobStopped
_ = os.Remove(joined)
if errors.Is(err, errJobStopped) {
return "", "", 0, nil, noop, errJobStopped
}
lPrintWarnf("拼接录播分段失败,本场只合并第一个分段(后半场没有弹幕):%v", err)
offset := float64(danmuStartNs-starts[0]) / 1e9
return files[0], jsonlFile, offset, noop, nil
return files[0], jsonlFile, offset, files[:1], noop, nil
}
newJsonl := filepath.Join(dir, chunkTempPrefix+"remapped.jsonl")
@@ -150,14 +152,14 @@ func prepareForMerge(ffmpegFile string, segs []recordSegment, jsonlFile string,
_ = os.Remove(joined)
lPrintWarnf("重映射弹幕时间失败,本场只合并第一个分段(后半场没有弹幕):%v", err)
offset := float64(danmuStartNs-starts[0]) / 1e9
return files[0], jsonlFile, offset, noop, nil
return files[0], jsonlFile, offset, files[:1], noop, nil
}
cleanup := func() {
_ = os.Remove(joined)
_ = os.Remove(newJsonl)
}
return joined, newJsonl, 0, cleanup, nil
return joined, newJsonl, 0, files, cleanup, nil
}
// 把弹幕时间从「真实时间轴」映射到「分段拼接后的时间轴」
@@ -316,7 +318,7 @@ func autoMergeJob(uid int, segs []recordSegment, jsonlFile string, danmuStartNs
return errors.New("开始任务失败:还有别的合并任务没结束,本场合并已跳过")
}
video, jsonl, offset, cleanup, err := prepareForMerge(ffmpegFile, segs, jsonlFile, danmuStartNs)
video, jsonl, offset, used, cleanup, err := prepareForMerge(ffmpegFile, segs, jsonlFile, danmuStartNs)
defer cleanup()
if err != nil {
lPrintErrf("自动合并失败:%v", err)
@@ -351,9 +353,9 @@ func autoMergeJob(uid int, segs []recordSegment, jsonlFile string, danmuStartNs
longID(uid)+" 的录播和弹幕合并完成\n"+outFile)
if config.MergeDelVideo {
// 源视频可能有多个分段,全部删掉
for _, s := range segs {
p := findMovedFile(s.file)
// 只删真正进了成片的分段:拼接中途回退成「只合并第一段」时,后面的分段没被合并,
// 按原分段列表全删就等于把后半场永久丢掉
for _, p := range used {
if p == "" {
continue
}