让 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 内存。
+185 -20
View File
@@ -130,8 +130,14 @@ type burnContext struct {
}
// 头像直径,与文字的实际墨迹高度一致(字号是 em 框,墨迹约为 0.8 倍)
// 头像直径:跟随弹幕字号。字号特别小时(有人把 danmuFontSize 填成 1、2),
// 算出来是 0,而 scale=0:0 会让 ffmpeg 直接以 EINVAL 退出、整块渲染失败
func avatarSize(fontSize int) int {
return fontSize * 4 / 5
n := fontSize * 4 / 5
if n < 1 {
n = 1
}
return n
}
// 头像和弹幕文字之间的间隔
@@ -801,10 +807,8 @@ func writeChunkAss(path string, meta danmuMeta, chunk danmuChunk, offset float64
fmt.Fprint(f, eventsTmpl)
for _, r := range chunk.records {
t0 := r.Time + offset
visStart := math.Max(t0, chunk.start)
visEnd := math.Min(t0+danmuShowSeconds, chunk.end)
if visEnd <= visStart {
visStart, visEnd, visible := danmuVisibleInChunk(r, offset, chunk)
if !visible {
continue
}
text := r.Name + "(" + strconv.FormatInt(r.UID, 10) + "):" + r.Content
@@ -817,13 +821,26 @@ func writeChunkAss(path string, meta danmuMeta, chunk danmuChunk, offset float64
return nil
}
// 一条弹幕在这一块里到底会不会被画出来:超出块的部分会被裁掉,裁完没有可见区间就整条跳过。
// 统计头像用量和生成 overlay 必须共用这一个判断 —— 只统计、不裁剪的话,同一个人在这块里
// 会被 split 出多余的份数,那些输出 pad 没人接收,ffmpeg 直接以 EINVAL 退出
// (Windows 上就是 exit status 0xffffffea,报错完全看不出跟弹幕有关,
// 还会连累三个编码器挨个"运行失败")
func danmuVisibleInChunk(r danmuRecord, offset float64, chunk danmuChunk) (visStart, visEnd float64, ok bool) {
t0 := r.Time + offset
visStart = math.Max(t0, chunk.start)
visEnd = math.Min(t0+danmuShowSeconds, chunk.end)
return visStart, visEnd, visEnd > visStart
}
// 渲染一个块:把这一块的弹幕(文字层 + 头像层)压进对应的视频片段
// cut 为 true 时表示这是分块渲染,需要按时长切出视频片段并重编码音频
// cb 用来回传 ffmpeg 的实时进度(已输出时长 / 倍速 / 帧率 / 已写出字节),可为 nil
func (c *burnContext) renderChunk(chunk danmuChunk, assBase, out string, cut bool, cb ffmpegProgress) error {
burnMode := c.videoFile != ""
// 统计这一块用到的头像,按首次出现顺序分配输入序号(输入 0 是视频或背景)
// 统计这一块用到的头像,按首次出现顺序分配输入序号(输入 0 是视频或背景)。
// 只算真正会画出来的弹幕,见 danmuVisibleInChunk 的说明
uidOrder := []int64{}
uidCount := make(map[int64]int)
uidInput := make(map[int64]int)
@@ -831,6 +848,9 @@ func (c *burnContext) renderChunk(chunk danmuChunk, assBase, out string, cut boo
if _, ok := c.avatars[r.UID]; !ok {
continue
}
if _, _, visible := danmuVisibleInChunk(r, c.offset, chunk); !visible {
continue
}
if _, seen := uidCount[r.UID]; !seen {
uidOrder = append(uidOrder, r.UID)
uidInput[r.UID] = len(uidOrder)
@@ -881,17 +901,18 @@ func (c *burnContext) renderChunk(chunk danmuChunk, assBase, out string, cut boo
if !ok {
continue
}
// 先判可见性再取 split 的份:被裁掉的弹幕不能占掉一份,
// 否则份数对不上,split 会留下没人接收的输出 pad(见 danmuVisibleInChunk)
visStart, visEnd, visible := danmuVisibleInChunk(r, c.offset, chunk)
if !visible {
continue
}
avLabel := strconv.Itoa(idx)
if uidCount[r.UID] > 1 {
avLabel = strconv.Itoa(idx) + "_" + strconv.Itoa(uidCopy[r.UID])
uidCopy[r.UID]++
}
t0 := r.Time + c.offset
visStart := math.Max(t0, chunk.start)
visEnd := math.Min(t0+danmuShowSeconds, chunk.end)
if visEnd <= visStart {
continue
}
// 头像在文字左侧并留出间隔:ASS 里的文字左边缘是这里加 ad+gap,头像跟着文字一起滚动。
// 位置公式 x = 画面宽 - (画面宽+总宽)*(t-t0)/10,这里 t 是块内时间,把 t0 也换算到块内坐标
total := ad + gap + r.Width
@@ -972,11 +993,14 @@ func (c *burnContext) renderChunk(chunk danmuChunk, assBase, out string, cut boo
return errJobStopped
}
lastErr = err
if i+1 < len(encoders) {
if i+1 < len(encoders) && encoderMightHelp(err) {
lPrintWarnf("编码器 %s 运行失败,回退到 %s:%v", enc.name, encoders[i+1].name, err)
continue
}
return fmt.Errorf("ffmpeg 运行失败:%v", lastErr)
// 跟编码器无关的失败(滤镜图建不起来、输入打不开等)换个编码器也是一样的结果,
// 直接把 ffmpeg 的报错写进日志,别让它烂在 exec 的一句 exit status 里
lPrintErrf("渲染失败:%v", err)
return fmt.Errorf("ffmpeg 运行失败:%v", err)
}
c.encoder = enc.name
return nil
@@ -1074,7 +1098,10 @@ func runFFmpegWith(ffmpegFile string, args []string, dir string, cb ffmpegProgre
}
cmd := exec.Command(ffmpegFile, full...)
cmd.Dir = dir
cmd.Stderr = os.Stderr
// 一边原样透传到控制台,一边留一份尾部:服务模式下 os.Stderr 没人看,
// 失败时日志里只剩一句 "exit status 0xffffffea",谁也查不出 ffmpeg 在抱怨什么
stderr := newTailWriter(ffmpegStderrKeep)
cmd.Stderr = io.MultiWriter(os.Stderr, stderr)
if cb != nil {
// StdoutPipe 要求 Stdout 还没被设置,所以这里不能先指到 os.Stdout
stdout, err := cmd.StdoutPipe()
@@ -1088,7 +1115,7 @@ func runFFmpegWith(ffmpegFile string, args []string, dir string, cb ffmpegProgre
defer jobDetach(cmd)
// 读到 EOF(进程退出会关闭管道)再 Wait,避免和 Wait 抢着关管道
scanFFmpegProgress(stdout, cb)
return cmd.Wait()
return ffmpegResult(cmd.Wait(), stderr.String())
}
cmd.Stdout = os.Stdout
if err := cmd.Start(); err != nil {
@@ -1096,7 +1123,105 @@ func runFFmpegWith(ffmpegFile string, args []string, dir string, cb ffmpegProgre
}
jobAttach(cmd)
defer jobDetach(cmd)
return cmd.Wait()
return ffmpegResult(cmd.Wait(), stderr.String())
}
// ffmpeg 的 stderr 尾部保留多少行:失败时够说明问题,又不至于把日志刷爆
const ffmpegStderrKeep = 40
// 只留最近若干行的 writer,给 ffmpeg 的 stderr 用(它会不停地刷进度行)
type tailWriter struct {
mu sync.Mutex
lines []string
buf []byte
keep int
}
func newTailWriter(keep int) *tailWriter {
return &tailWriter{keep: keep}
}
func (w *tailWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
for _, b := range p {
// ffmpeg 的进度行用 \r 覆盖,这里把 \r 也当行尾处理
if b == '\n' || b == '\r' {
w.flushLine()
continue
}
w.buf = append(w.buf, b)
}
return len(p), nil
}
func (w *tailWriter) flushLine() {
if len(bytes.TrimSpace(w.buf)) > 0 && !ffmpegNoise(string(w.buf)) {
w.lines = append(w.lines, string(w.buf))
if len(w.lines) > w.keep {
w.lines = w.lines[1:]
}
}
w.buf = w.buf[:0]
}
// ffmpeg 每次启动都要打一段版本和编译配置(configuration 那行有一千多字符),
// 留着它就把真正有用的报错挤出日志了
var ffmpegNoisePrefix = []string{
"ffmpeg version ", " built with ", " configuration:", " libav", " libsw",
}
func ffmpegNoise(line string) bool {
for _, p := range ffmpegNoisePrefix {
if strings.HasPrefix(line, p) {
return true
}
}
return false
}
// 尾部内容(含还没有换行的最后一段)
func (w *tailWriter) String() string {
w.mu.Lock()
defer w.mu.Unlock()
lines := append([]string{}, w.lines...)
if len(bytes.TrimSpace(w.buf)) > 0 {
lines = append(lines, string(w.buf))
}
return strings.Join(lines, "\n")
}
// 把 ffmpeg 自己的报错附在错误上:不然只剩一句 exit status,查不出原因
func ffmpegResult(err error, stderr string) error {
if err == nil {
return nil
}
if msg := strings.TrimSpace(stderr); msg != "" {
return fmt.Errorf("%v\n--- ffmpeg 最后的输出 ---\n%s", err, msg)
}
return err
}
// 这些报错说明问题在滤镜图或输入上,跟用哪个编码器无关:
// 挨个回退只会把同一件事重跑三遍,日志里刷三行、浪费时间还看不出区别
var notEncoderFault = []string{
"Error binding filtergraph",
"Error initializing filtergraph",
"Error reinitializing filters",
"Error parsing filterchain",
"Error opening input",
"Invalid data found when processing input",
}
// 换编码器值不值得:只有跟编码器本身有关的失败才值得回退
func encoderMightHelp(err error) bool {
msg := err.Error()
for _, s := range notEncoderFault {
if strings.Contains(msg, s) {
return false
}
}
return true
}
// 解析 -progress pipe:1 的输出:每行一个 key=value,
@@ -1193,12 +1318,33 @@ func avatarCacheName(uid int64, url string) string {
return fmt.Sprintf("%d_%x.png", uid, sum[:6])
}
// 缓存的头像是不是一个完整的 PNG:PNG 必须以 IEND 块收尾。
// 只看「文件在不在」是不够的 —— 下载中断、写盘失败都会留下半截文件,
// 而它下次会被当成可用缓存塞给 ffmpeg 当输入,那一块直接渲染失败,
// 报的还是 exit status 0xffffffea 这类完全看不出跟头像有关的信息
func validCachedAvatar(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
info, err := f.Stat()
if err != nil || info.Size() < 16 {
return false
}
tail := make([]byte, 12) // IEND 块:4 字节长度 + "IEND" + 4 字节 CRC
if _, err := f.ReadAt(tail, info.Size()-12); err != nil {
return false
}
return bytes.Equal(tail[4:8], []byte("IEND"))
}
// 下载头像并圆形裁切,失败自动重试,返回缓存的 PNG 路径,失败返回空字符串。
// legacyDirs 是旧版本放缓存的位置,命中就搬过来用,省得换保存目录后重新下一次。
func ensureAvatar(uid int64, url, cacheDir string, legacyDirs ...string) string {
name := avatarCacheName(uid, url)
path := filepath.Join(cacheDir, name)
if _, err := os.Stat(path); err == nil {
if validCachedAvatar(path) {
return path
}
if err := os.MkdirAll(cacheDir, 0755); err != nil {
@@ -1215,13 +1361,21 @@ func ensureAvatar(uid int64, url, cacheDir string, legacyDirs ...string) string
continue
}
if err := os.Rename(old, path); err == nil {
if validCachedAvatar(path) {
return path
}
_ = os.Remove(path) // 旧的也是半截文件,删掉重新下
break
}
// 跨盘改名会失败,退回复制
if copyFile(old, path) == nil {
if validCachedAvatar(path) {
_ = os.Remove(old)
return path
}
_ = os.Remove(path)
break
}
}
// 清掉这个用户已经失效的旧头像
for _, dir := range append([]string{cacheDir}, legacyDirs...) {
@@ -1297,12 +1451,23 @@ func downloadAvatar(uid int64, url, path string) error {
return err
}
out, err := os.Create(path)
// 先写临时文件再改名:写一半失败留下的半截文件不能被当成可用缓存,
// 否则下次命中它就会把残图塞给 ffmpeg 当输入,整块渲染跟着失败
tmp := path + ".part"
out, err := os.Create(tmp)
if err != nil {
return err
}
defer out.Close()
return png.Encode(out, circleAvatar(src, avatarCacheSize))
if err := png.Encode(out, circleAvatar(src, avatarCacheSize)); err != nil {
out.Close()
_ = os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
_ = os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
}
// 解码头像图片。AcFun 的头像里混着 gif 和 webp:
+309
View File
@@ -0,0 +1,309 @@
package main
import (
"bytes"
"errors"
"fmt"
"image"
"image/png"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
)
// ==================== ffmpeg 报错的可见性 ====================
// 尾部 writer 只留最近若干行(ffmpeg 的进度行会不停刷)
func TestTailWriterKeepsLastLines(t *testing.T) {
w := newTailWriter(3)
fmt.Fprint(w, "第1行\n第2行\n第3行\r第4行\n第5行\n")
got := w.String()
if !strings.Contains(got, "第5行") {
t.Fatalf("应当保留最后一行:%q", got)
}
if strings.Contains(got, "第1行") || strings.Contains(got, "第2行") {
t.Fatalf("超出保留行数的旧内容应当被丢掉:%q", got)
}
if lines := strings.Split(got, "\n"); len(lines) != 3 {
t.Fatalf("应当只留 3 行,实际 %d 行:%q", len(lines), got)
}
}
// 失败时要把 ffmpeg 自己的报错带上,不然只剩一句 exit status
func TestFFmpegResultCarriesStderr(t *testing.T) {
err := ffmpegResult(errors.New("exit status 0xffffffea"), "Error binding filtergraph inputs/outputs")
if err == nil {
t.Fatal("应当返回错误")
}
msg := err.Error()
if !strings.Contains(msg, "exit status 0xffffffea") || !strings.Contains(msg, "Error binding filtergraph") {
t.Fatalf("错误里应当同时有退出状态和 ffmpeg 的说明,实际:%s", msg)
}
// 成功时不带任何东西
if got := ffmpegResult(nil, "随便什么输出"); got != nil {
t.Fatalf("成功时应当返回 nil,实际 %v", got)
}
// 没有 stderr 时保持原样
plain := errors.New("exit status 1")
if got := ffmpegResult(plain, " "); got != plain {
t.Fatalf("没有 ffmpeg 输出时应当原样返回,实际 %v", got)
}
}
// 跟编码器无关的失败不该挨个回退重试
func TestEncoderMightHelp(t *testing.T) {
unrelated := []string{
"Error binding filtergraph inputs/outputs: Invalid argument",
"Error initializing filtergraph",
"Error opening input: No such file or directory",
}
for _, msg := range unrelated {
if encoderMightHelp(errors.New(msg)) {
t.Fatalf("这类失败跟编码器无关,不该回退重试:%s", msg)
}
}
related := []string{
"Error initializing output stream 0:0 -- Error while opening encoder",
"hevc_qsv: Function not implemented",
}
for _, msg := range related {
if !encoderMightHelp(errors.New(msg)) {
t.Fatalf("编码器自身的失败应当允许回退:%s", msg)
}
}
}
// ==================== 头像缓存完整性 ====================
func tinyPNG(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 8, 8))); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// 半截的 PNG 不能被当成可用缓存
func TestValidCachedAvatar(t *testing.T) {
dir := t.TempDir()
good := tinyPNG(t)
full := filepath.Join(dir, "full.png")
if err := os.WriteFile(full, good, 0644); err != nil {
t.Fatal(err)
}
if !validCachedAvatar(full) {
t.Fatal("完整的 PNG 应当算有效")
}
// 砍掉尾巴(模拟下载中断)
cut := filepath.Join(dir, "cut.png")
if err := os.WriteFile(cut, good[:len(good)-8], 0644); err != nil {
t.Fatal(err)
}
if validCachedAvatar(cut) {
t.Fatal("半截 PNG 不该算有效")
}
empty := filepath.Join(dir, "empty.png")
if err := os.WriteFile(empty, nil, 0644); err != nil {
t.Fatal(err)
}
if validCachedAvatar(empty) {
t.Fatal("空文件不该算有效")
}
if validCachedAvatar(filepath.Join(dir, "不存在.png")) {
t.Fatal("不存在的文件不该算有效")
}
}
// 命中的是半截缓存时要重新下载,下载完必须是完整 PNG
func TestEnsureAvatarRedownloadsBrokenCache(t *testing.T) {
withJobFlags(t, true, false, false)
dir := t.TempDir()
var hits int64
body := tinyPNG(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&hits, 1)
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(body)
}))
defer srv.Close()
url := srv.URL + "/a.png"
path := filepath.Join(dir, avatarCacheName(42, url))
// 先摆一个「写了一半」的缓存
if err := os.WriteFile(path, body[:len(body)-8], 0644); err != nil {
t.Fatal(err)
}
if validCachedAvatar(path) {
t.Fatal("前置条件不成立:半截文件应当被判为无效")
}
got := ensureAvatar(42, url, dir)
if got != path {
t.Fatalf("应当返回缓存路径 %q,实际 %q", path, got)
}
if atomic.LoadInt64(&hits) == 0 {
t.Fatal("半截缓存应当触发重新下载")
}
if !validCachedAvatar(path) {
t.Fatal("重新下载之后应当是完整的 PNG")
}
}
// 完好的缓存不该再走网络
func TestEnsureAvatarUsesGoodCache(t *testing.T) {
withJobFlags(t, true, false, false)
dir := t.TempDir()
var hits int64
body := tinyPNG(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&hits, 1)
_, _ = w.Write(body)
}))
defer srv.Close()
url := srv.URL + "/b.png"
path := filepath.Join(dir, avatarCacheName(43, url))
if err := os.WriteFile(path, body, 0644); err != nil {
t.Fatal(err)
}
if got := ensureAvatar(43, url, dir); got != path {
t.Fatalf("应当直接命中缓存,实际 %q", got)
}
if n := atomic.LoadInt64(&hits); n != 0 {
t.Fatalf("完好的缓存不该再请求网络,实际 %d 次", n)
}
}
// 下载过程中不会留下能被误用的半截文件
func TestDownloadAvatarWritesAtomically(t *testing.T) {
withJobFlags(t, true, false, false)
dir := t.TempDir()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(tinyPNG(t))
}))
defer srv.Close()
url := srv.URL + "/c.png"
path := filepath.Join(dir, avatarCacheName(44, url))
if err := downloadAvatar(44, url, path); err != nil {
t.Fatalf("下载不该失败:%v", err)
}
if !validCachedAvatar(path) {
t.Fatal("下载完的缓存应当是完整 PNG")
}
// 临时文件不该留下
if entries, err := os.ReadDir(dir); err == nil {
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".part") {
t.Fatalf("不该留下临时文件:%s", e.Name())
}
}
}
}
// 记录当前日志长度,返回一个能读到之后新增内容的函数
func logCursor() func() string {
logString.Lock()
start := logString.str.Len()
logString.Unlock()
return func() string {
logString.Lock()
defer logString.Unlock()
data := logString.str.String()
if start > len(data) {
return data // 中间被 Reset 过,那就给全部
}
return data[start:]
}
}
// 头像输入打不开属于「跟编码器无关」的失败:不该挨个编码器回退重试,
// 而且 ffmpeg 自己的报错必须留在日志里(否则只剩一句 exit status 0xffffffea)
func TestRenderChunkDoesNotFallBackOnUnrelatedFailure(t *testing.T) {
ffmpegFile := getFFmpeg()
if ffmpegFile == "" {
t.Skip("找不到 ffmpeg,跳过")
}
withJobFlags(t, true, false, false)
dir := t.TempDir()
video := filepath.Join(dir, "in.mp4")
makeTinyVideo(t, ffmpegFile, video, "red")
uid := int64(1)
chunk := danmuChunk{start: 0, end: 1, records: []danmuRecord{
{Time: 0.2, UID: uid, Name: "甲", Content: "正常一条", Lane: 0, Width: 100},
}}
ctx := &burnContext{
ffmpegFile: ffmpegFile,
videoFile: video,
outFile: filepath.Join(dir, "out.mp4"),
meta: danmuMeta{PlayResX: 160, PlayResY: 120, FontSize: 20},
// 头像文件根本不存在:ffmpeg 打开输入就会失败
avatars: map[int64]string{uid: filepath.Join(dir, "根本没有这个头像.png")},
dir: dir,
// 故意不指定 encoder:从候选列表第一个开始,才能看出有没有挨个回退
}
ad, gap := avatarSize(ctx.meta.FontSize), avatarGap(ctx.meta.FontSize)
if err := writeChunkAss(filepath.Join(dir, "chunk.ass"), ctx.meta, chunk, 0, ad, gap); err != nil {
t.Fatal(err)
}
read := logCursor()
err := ctx.renderChunk(chunk, "chunk.ass", filepath.Join(dir, "out.mp4"), false, nil)
if err == nil {
t.Fatal("头像打不开时应当渲染失败")
}
msg := err.Error()
if !strings.Contains(msg, "ffmpeg 最后的输出") {
t.Fatalf("错误里应当带上 ffmpeg 自己的输出:%s", msg)
}
if log := read(); strings.Contains(log, "回退到") {
t.Fatalf("输入打不开跟编码器无关,不该挨个回退重试:\n%s", log)
}
}
// 启动时那段版本/编译配置要过滤掉,别把真正的报错挤出日志
func TestTailWriterSkipsStartupNoise(t *testing.T) {
w := newTailWriter(10)
fmt.Fprint(w, "ffmpeg version n9.0.2-3\n"+
" built with gcc 16.2.0\n"+
" configuration: --prefix=/ffbuild --enable-gpl --enable-libass 等等很长的一行\n"+
" libavutil 61. 1.102 / 61. 1.102\n"+
" libswscale 10. 1.102 / 10. 1.102\n"+
"[in#0 @ 0x1] moov atom not found\n"+
"Error opening input files: Invalid data found when processing input\n")
got := w.String()
for _, noise := range []string{"ffmpeg version", "configuration:", "libavutil", "libswscale", "built with"} {
if strings.Contains(got, noise) {
t.Fatalf("启动信息应当被过滤掉(%s),实际:%q", noise, got)
}
}
if !strings.Contains(got, "moov atom not found") || !strings.Contains(got, "Invalid data found") {
t.Fatalf("真正的报错必须保留:%q", got)
}
}
// 头像直径再小也要 >= 1,否则 scale=0:0 会让 ffmpeg 直接 EINVAL
func TestAvatarSizeNeverZero(t *testing.T) {
for _, fs := range []int{0, 1, 2, 5, 42, 60} {
if got := avatarSize(fs); got < 1 {
t.Fatalf("字号 %d 算出的头像直径是 %d,应当至少为 1", fs, got)
}
}
if got := avatarSize(60); got != 48 {
t.Fatalf("正常字号的口径不该变:60 -> %d(应为 48)", got)
}
}
+56
View File
@@ -0,0 +1,56 @@
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)
}
}