让 ffmpeg 的报错能进日志,并堵住三处会直接毁掉整块渲染的坑

背景:线上第 153/648 块失败,三个编码器依次报同一个 "exit status 0xffffffea"
(EINVAL)。可用的线索只有这一句 —— 因为 ffmpeg 自己的输出走的是 os.Stderr,
服务模式下没人看,等于丢掉了。所以这轮先解决"看不到原因",再顺手补几处防御。

一、把 ffmpeg 的报错留在日志里
   runFFmpegWith 给 stderr 挂一个只保留最后 40 行的 writer(进度行用 \r 刷新,
   一并按行处理),失败时把这段附在错误上再由上层 lPrintErrf 写进日志。
   顺带过滤掉每次启动都打印的版本/编译配置那几行(configuration 一行就一千多字符,
   留着会把真正的报错挤出去)。实测效果:报错里直接能看到
   "moov atom not found" / "Error opening input: Invalid data found"。

二、跟编码器无关的失败不再挨个回退
   滤镜图建不起来、输入文件打不开这类问题,换个编码器结果一模一样。
   原来会把同一件事重跑三遍(hevc_qsv → h264_qsv → libx264),既浪费时间
   又在日志里刷三行看不出区别的错误。现在命中这些关键词就直接报出完整错误。

三、头像 split 的份数必须和真正用到的份数一致
   统计头像用量时按整块弹幕算,生成 overlay 时才做可见性裁剪:
   同一 uid 在本块有多条、其中一条整条落在块外的话,split 会多出一个没人接收的输出 pad,
   ffmpeg 直接 EINVAL 退出(报错完全看不出跟弹幕有关)。抽出 danmuVisibleInChunk
   让统计、生成 overlay、写块字幕三处用同一个判断。

四、缓存命中要校验文件完整,下载改成原子写
   原来只看"文件在不在",被写一半的 PNG 会被当成可用缓存塞给 ffmpeg 当输入。
   现在要求 PNG 以 IEND 收尾(16 字节以下直接判无效),下载先写 .part 再改名,
   旧位置搬过来的文件同样要过这道校验。

五、头像直径至少为 1
   有人把 danmuFontSize 填成 1、2 时 avatarSize 会算出 0,而 scale=0:0
   会让 ffmpeg 直接 EINVAL、整块渲染失败。

验证:
- gofmt / go vet / go build 通过;go test 全绿(16.8s);go test -race 全绿(22.8s);
  staticcheck -checks=all 零告警
- 新增 diagnostics_test.go(10 个)与 split_pad_test.go(1 个)用例;
  反向验证扩到 21 项全过
- 隔离实例:e2e.py 37 项、smoke_extra.py 33 项、smoke_late_danmu.py(越界弹幕场景)全过;
  另用一个损坏的 mp4 实测,任务失败时日志里能看到 ffmpeg 的原话,
  且没有出现编码器回退
This commit is contained in:
tamakyi
2026-09-26 12:15:14 +08:00
parent a2b7185a7b
commit 3eb4aa3102
4 changed files with 586 additions and 26 deletions
+33 -3
View File
@@ -150,7 +150,13 @@ func TestEnsureAvatarMigratesLegacyCache(t *testing.T) {
sum := sha1.Sum([]byte(url))
name := fmt.Sprintf("%d_%x.png", int64(7), sum[:6])
old := filepath.Join(legacyDir, name)
if err := os.WriteFile(old, []byte("cached-png"), 0644); err != nil {
// 旧位置放的是真 PNG(旧版本自己也是用 png.Encode 写的):
// 现在搬过来之前会校验完整性,随便一段字节的话会被当成半截文件丢掉
var pngBuf bytes.Buffer
if err := png.Encode(&pngBuf, image.NewRGBA(image.Rect(0, 0, 8, 8))); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(old, pngBuf.Bytes(), 0644); err != nil {
t.Fatal(err)
}
@@ -160,8 +166,8 @@ func TestEnsureAvatarMigratesLegacyCache(t *testing.T) {
t.Fatalf("应搬到 %q,实际 %q", want, got)
}
data, err := os.ReadFile(want)
if err != nil || string(data) != "cached-png" {
t.Fatalf("搬过来的内容不对:%v %q", err, data)
if err != nil || !bytes.Equal(data, pngBuf.Bytes()) {
t.Fatalf("搬过来的内容不对:%v(%d 字节 vs %d 字节)", err, len(data), pngBuf.Len())
}
if _, err := os.Stat(old); !os.IsNotExist(err) {
t.Errorf("旧位置的文件应被搬走,实际还在:%v", err)
@@ -173,6 +179,30 @@ func TestEnsureAvatarMigratesLegacyCache(t *testing.T) {
}
}
// 旧位置那份也是半截文件时,不能搬过来用:会一路传给 ffmpeg 当输入
func TestEnsureAvatarRejectsBrokenLegacyCache(t *testing.T) {
withJobFlags(t, true, false, false)
root := t.TempDir()
cacheDir := filepath.Join(root, "program", avatarCacheDir)
legacyDir := filepath.Join(root, "record", avatarCacheDir)
if err := os.MkdirAll(legacyDir, 0755); err != nil {
t.Fatal(err)
}
url := "http://127.0.0.1:9/never.png"
old := filepath.Join(legacyDir, avatarCacheName(8, url))
if err := os.WriteFile(old, []byte("这根本不是 PNG"), 0644); err != nil {
t.Fatal(err)
}
// 连不通的地址 + 坏缓存 → 拿不到头像,但绝不能把坏文件留在缓存目录里
if got := ensureAvatar(8, url, cacheDir); got != "" {
t.Fatalf("坏缓存不该被采用,实际返回 %q", got)
}
if validCachedAvatar(filepath.Join(cacheDir, avatarCacheName(8, url))) {
t.Fatal("坏文件不该出现在缓存目录里")
}
}
// ==================== 头像下载的容量上限 ====================
// CDN 的响应大小和图片尺寸都是外部输入,必须有上限。
// 改之前 downloadAvatar 用的是裸 io.ReadAll:实测一个头像就能吃掉 512MB 内存。