diff --git a/avatar_test.go b/avatar_test.go index 9330c8d..7ee5af4 100644 --- a/avatar_test.go +++ b/avatar_test.go @@ -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 内存。 diff --git a/danmuburn.go b/danmuburn.go index 2587e62..6298178 100644 --- a/danmuburn.go +++ b/danmuburn.go @@ -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,12 +1361,20 @@ func ensureAvatar(uid int64, url, cacheDir string, legacyDirs ...string) string continue } if err := os.Rename(old, path); err == nil { - return path + if validCachedAvatar(path) { + return path + } + _ = os.Remove(path) // 旧的也是半截文件,删掉重新下 + break } // 跨盘改名会失败,退回复制 if copyFile(old, path) == nil { - _ = os.Remove(old) - return path + if validCachedAvatar(path) { + _ = os.Remove(old) + return path + } + _ = os.Remove(path) + break } } // 清掉这个用户已经失效的旧头像 @@ -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: diff --git a/diagnostics_test.go b/diagnostics_test.go new file mode 100644 index 0000000..50772bf --- /dev/null +++ b/diagnostics_test.go @@ -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) + } +} diff --git a/split_pad_test.go b/split_pad_test.go new file mode 100644 index 0000000..27fd9e6 --- /dev/null +++ b/split_pad_test.go @@ -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) + } +}