背景:线上第 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 的原话, 且没有出现编码器回退
57 lines
1.8 KiB
Go
57 lines
1.8 KiB
Go
package main
|
||
|
||
import (
|
||
"image"
|
||
"image/png"
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
)
|
||
|
||
// 一块里同一个人有多条弹幕、但其中一条整条落在块外(会被裁掉)时,
|
||
// 头像的 split 份数必须和真正用到的份数一致 —— 否则 split 会多出一个没人接收的输出 pad,
|
||
// ffmpeg 直接以 EINVAL(Windows 上就是 exit status 0xffffffea)退出,
|
||
// 报错完全看不出跟弹幕有关,还会连累三个编码器挨个"失败"。
|
||
func TestRenderChunkMatchesSplitWithVisibleDanmu(t *testing.T) {
|
||
ffmpegFile := getFFmpeg()
|
||
if ffmpegFile == "" {
|
||
t.Skip("找不到 ffmpeg,跳过")
|
||
}
|
||
withJobFlags(t, true, false, false)
|
||
dir := t.TempDir()
|
||
|
||
// 头像
|
||
av := filepath.Join(dir, "av.png")
|
||
f, err := os.Create(av)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := png.Encode(f, image.NewRGBA(image.Rect(0, 0, 16, 16))); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
f.Close()
|
||
|
||
// 同一个 uid 两条弹幕:一条在块内可见,另一条时间远在块外
|
||
uid := int64(7)
|
||
chunk := danmuChunk{start: 0, end: 1, records: []danmuRecord{
|
||
{Time: 0.2, UID: uid, Name: "甲", Content: "块内可见的", Lane: 0, Width: 100},
|
||
{Time: 30.0, UID: uid, Name: "甲", Content: "整条在块外的", Lane: 1, Width: 100},
|
||
}}
|
||
|
||
ctx := &burnContext{
|
||
ffmpegFile: ffmpegFile,
|
||
outFile: filepath.Join(dir, "out.mp4"),
|
||
meta: danmuMeta{PlayResX: 160, PlayResY: 120, FontSize: 20},
|
||
avatars: map[int64]string{uid: av},
|
||
dir: dir,
|
||
encoder: "libx264", // 直接指定软编,别让 QSV 的兼容性干扰这条用例
|
||
}
|
||
out := filepath.Join(dir, "chunk.mov")
|
||
if err := ctx.renderChunk(chunk, "chunk.ass", out, false, nil); err != nil {
|
||
t.Fatalf("同一个人有两条弹幕、其中一条在块外时不该渲染失败:%v", err)
|
||
}
|
||
if !fileExists(out) {
|
||
t.Fatalf("应当产出文件:%s", out)
|
||
}
|
||
}
|