From 8e82aa54c694ac7a78ecdaa8f177fdac2c128c12 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Sat, 26 Sep 2026 09:35:51 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E7=AC=AC=E4=BA=8C=E6=89=B9=20P1?= =?UTF-8?q?=EF=BC=9A=E5=90=88=E5=B9=B6=E6=94=B6=E5=B0=BE=E3=80=81=E5=88=86?= =?UTF-8?q?=E5=9D=97=E4=B8=8A=E9=99=90=E3=80=81=E7=A1=AE=E5=AE=9A=E6=80=A7?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E4=B8=8E=20ffmpeg=20=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=BD=92=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、合并收尾只删真正进了成片的分段 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 次、停止后无临时文件残留) --- automerge.go | 32 +++--- chunks_test.go | 295 +++++++++++++++++++++++++++++++++++++++++++++++++ danmuburn.go | 26 ++++- manualmerge.go | 3 +- record.go | 17 ++- utils.go | 20 ++++ 6 files changed, 371 insertions(+), 22 deletions(-) create mode 100644 chunks_test.go diff --git a/automerge.go b/automerge.go index 2ad1638..e3bd44a 100644 --- a/automerge.go +++ b/automerge.go @@ -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 } diff --git a/chunks_test.go b/chunks_test.go new file mode 100644 index 0000000..9c076fc --- /dev/null +++ b/chunks_test.go @@ -0,0 +1,295 @@ +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) + } +} diff --git a/danmuburn.go b/danmuburn.go index c1516c1..56c9f50 100644 --- a/danmuburn.go +++ b/danmuburn.go @@ -61,6 +61,13 @@ const danmuShowSeconds = 10.0 // 弹幕一多整条命令就会超限且报错完全看不出原因,所以超过这个数量就分块渲染再拼接。 const maxDanmuPerChunk = 80 +// 单块条数的硬上限。块尾会把「显示时间跨过块尾」的弹幕一并并入, +// 弹幕密集时(10 秒停留窗口里塞进几百条)单块就会膨胀到几百条 —— +// 一条弹幕在 -filter_complex 里约占 110 字节,几百条足以顶破上面那条命令行上限, +// 表现同样是「合并失败且看不出原因」,所以并入也要有个头。 +// 到上限就把块尾收到下一条弹幕出现之前,让剩下的弹幕落到下一块。 +const maxDanmuPerChunkHard = 200 + // 分块渲染产生的临时文件前缀 const chunkTempPrefix = "acfunlive_chunk_" @@ -645,11 +652,18 @@ func splitDanmuChunks(records []danmuRecord, offset, maxEnd float64) []danmuChun if end <= start { end = start + danmuShowSeconds } - // 起点早于块尾的弹幕(显示会跨过块尾)也并入这一块 + // 起点早于块尾的弹幕(显示会跨过块尾)也并入这一块,但不许超过硬上限 j := limit - for j < n && records[j].Time+offset < end { + for j < n && j < i+maxDanmuPerChunkHard && records[j].Time+offset < end { j++ } + // 被硬上限挡住时,把块尾收紧到下一条弹幕出现之前:它因此落在下一块里, + // 而且不会因为「整条都早于下一块块头」在下一块里被当成无效区间丢掉 + if j < n && records[j].Time+offset < end { + if t := records[j].Time + offset; t > start { + end = t + } + } chunks = append(chunks, danmuChunk{start: start, end: end, records: records[i:j]}) i = j start = end @@ -1024,6 +1038,10 @@ func concatChunks(ffmpegFile string, files []string, out, dir string, cb ffmpegP defer os.Remove(listFile) if err := runFFmpegWith(ffmpegFile, []string{"-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c", "copy", out}, dir, cb); err != nil { + // 停止任务时杀掉 ffmpeg 也会让它非 0 退出,这是「已停止」不是「拼接失败」 + if jobStopRequested() { + return errJobStopped + } return fmt.Errorf("拼接各分块失败:%v", err) } return nil @@ -1131,7 +1149,7 @@ func downloadAvatar(uid int64, url, path string) error { return err } if len(data) > avatarMaxBytes { - return fmt.Errorf("头像体积超过 %d MB,不下载", avatarMaxBytes>>20) + return noRetry(fmt.Errorf("头像体积超过 %d MB,不下载", avatarMaxBytes>>20)) } src, err := decodeAvatar(data, resp.Header.Get("Content-Type")) if err != nil { @@ -1157,7 +1175,7 @@ func decodeAvatar(data []byte, contentType string) (image.Image, error) { // 防压缩炸弹:几十 KB 的 PNG/GIF 能解压出上亿像素,几 MB 的文件就能吃掉几 GB 内存。 // 头像最后只会被缩到几十像素(跟随字号),根本用不到这么大的原图 if b := src.Bounds(); b.Dx()*b.Dy() > avatarMaxPixels { - return nil, fmt.Errorf("头像尺寸 %dx%d 超出上限(最多 %d 像素)", b.Dx(), b.Dy(), avatarMaxPixels) + return nil, noRetry(fmt.Errorf("头像尺寸 %dx%d 超出上限(最多 %d 像素)", b.Dx(), b.Dy(), avatarMaxPixels)) } return src, nil } diff --git a/manualmerge.go b/manualmerge.go index 05ed739..2c445de 100644 --- a/manualmerge.go +++ b/manualmerge.go @@ -505,7 +505,8 @@ func runManualMerge(plan mergePlan) error { } // 复用自动合并那条链路:多段会先拼接并把弹幕时间重映射到拼接后的时间轴 - video, jsonl, autoOffset, cleanup, err := prepareForMerge(ffmpegFile, segs, src.jsonlPath, danmuStartNs) + // (手动合并不删源视频,所以这里用不上「实际参与合并的文件」) + video, jsonl, autoOffset, _, cleanup, err := prepareForMerge(ffmpegFile, segs, src.jsonlPath, danmuStartNs) defer cleanup() if err != nil { return err diff --git a/record.go b/record.go index fc959ff..507521b 100644 --- a/record.go +++ b/record.go @@ -46,7 +46,7 @@ func getFFmpeg() string { } if p, err := exec.LookPath("ffmpeg"); err == nil { - return p + return absExe(p) } for _, dir := range []string{ @@ -57,12 +57,25 @@ func getFFmpeg() string { } { p := filepath.Join(dir, name) if info, err := os.Stat(p); err == nil && !info.IsDir() { - return p + return absExe(p) } } return "" } +// 转成绝对路径再交给 exec:合并时 ffmpeg 的工作目录会被改成输出目录, +// 相对路径到那边就找不着了;而且 Windows 上 exec 会直接拒绝执行 +// 「相对当前目录」找到的可执行文件(报错信息完全看不出跟路径有关) +func absExe(p string) string { + if p == "" || filepath.IsAbs(p) { + return p + } + if abs, err := filepath.Abs(p); err == nil { + return abs + } + return p +} + // 检查设置里指定的 ffmpeg 路径,返回错误信息,空字符串表示可用(或没填) func checkFFmpegPath(path string) string { p := strings.TrimSpace(path) diff --git a/utils.go b/utils.go index 3276a8d..0d9e58b 100644 --- a/utils.go +++ b/utils.go @@ -3,6 +3,7 @@ package main import ( "context" + "errors" "fmt" "io" "log" @@ -119,11 +120,30 @@ func safeGo(name string, f func()) { }() } +// 不需要重试的错误:再试一遍也只是重复同样的失败(比如头像本身就超出体积/尺寸上限), +// 硬重试只会白白把几 MB 的响应重新下一遍 +type noRetryError struct{ err error } + +func (e noRetryError) Error() string { return e.err.Error() } +func (e noRetryError) Unwrap() error { return e.err } + +// 把错误标记成「重试也没用」 +func noRetry(err error) error { + if err == nil { + return nil + } + return noRetryError{err} +} + // 尝试运行,三次出错后结束运行 func runThrice(f func() error) error { var err error for retry := 0; retry < 3; retry++ { if err = f(); err != nil { + var nr noRetryError + if errors.As(err, &nr) { + return err + } log.Printf("%v", err) } else { return nil