让 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:
+188
-23
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user