起因是核查合并落盘和头像处理时发现的两处问题:
1) 成片落盘:ffmpeg 原来是直接写目标目录(automerge.go 里那句注释「不再事后再
移动一次」)。成片目录可能是网络盘或外接盘,写到一半失败会在那里留下一个
看起来正常的半成品。现在改成先本地合成、完成后再落盘:
- 输出先写到弹幕文件所在目录(本地)的 acfunlive_chunk_merging_*
- 写完后由 placeOutput 落盘:同盘直接 os.Rename(瞬时),跨盘退回拷贝;
拷贝中途失败留下的是 *.acfunlive.part,一眼看得出不是成品
- 成功或失败都会清掉本地临时文件,失败时两个目录都不留东西
- 临时文件沿用 chunkTempPrefix,不再出现在文件管理的列表里(listFiles 过滤)
- 启动时清理上次残留;删不掉时提示一句 —— 强杀程序不会连带杀掉 ffmpeg
子进程,它可能还在写这个文件,这种情况下次启动会再清一次
2) 头像:downloadAvatar 只注册了 jpeg 和 png 解码器,AcFun 上的 gif 头像必然
解码失败,静态 webp 同样。加上 _ "image/gif"(多帧取第一帧)和
_ "golang.org/x/image/webp"(x/image 本来就是直接依赖,零新增模块)。
动画 webp 两条路都解不开(x/image 报 invalid format,ffmpeg 5.0 也不行),
保持只烧文字。解码失败时错误信息带上 Content-Type,方便排查是哪张图。
在隔离实例上用真 ffmpeg 跑了真合并验证:gif 头像和 webp 头像都进了缓存并烧进
画面(抽帧确认圆形头像);日志里 ffmpeg 的输出是 record 下的临时文件,完成后
才出现在 merged;坏视频合成失败后成片目录没有半成品、录播目录零残留;合并
中途强杀留下的临时文件在下次启动时被清掉。
新增 output_test.go(7 个)和 avatar_test.go(3 个);go test ./...、gofmt、
go vet 干净。
112 lines
3.2 KiB
Go
112 lines
3.2 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"image"
|
|
"image/color"
|
|
"image/gif"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"testing"
|
|
)
|
|
|
|
// 2x2 的静态 webp(纯红),用来确认 webp 解码器确实注册上了
|
|
const tinyWebPBase64 = "UklGRjwAAABXRUJQVlA4IDAAAADQAQCdASoCAAIAAgA0JaACdLoB+AADsAD+8Oj3/yC5YXXI1/8gP+QH/ID/+PIAAAA="
|
|
|
|
// 多帧 gif 解出来应该是第一帧(AcFun 上有动图头像,压成静态就用第一帧)
|
|
func TestDecodeAvatarGifTakesFirstFrame(t *testing.T) {
|
|
pal := color.Palette{color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255}}
|
|
first := image.NewPaletted(image.Rect(0, 0, 8, 8), pal)
|
|
second := image.NewPaletted(image.Rect(0, 0, 8, 8), pal)
|
|
for i := range second.Pix {
|
|
second.Pix[i] = 1
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := gif.EncodeAll(&buf, &gif.GIF{
|
|
Image: []*image.Paletted{first, second},
|
|
Delay: []int{10, 10},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
src, err := decodeAvatar(buf.Bytes(), "image/gif")
|
|
if err != nil {
|
|
t.Fatalf("gif 解码失败:%v", err)
|
|
}
|
|
r, g, b, _ := src.At(4, 4).RGBA()
|
|
if r>>8 != 255 || g>>8 != 0 || b>>8 != 0 {
|
|
t.Errorf("应该取第一帧(红),实际拿到 r=%d g=%d b=%d", r>>8, g>>8, b>>8)
|
|
}
|
|
}
|
|
|
|
// jpg / png / 静态 webp 都能解,解不出来的要给中文原因
|
|
func TestDecodeAvatarFormats(t *testing.T) {
|
|
var pngBuf, jpgBuf bytes.Buffer
|
|
img := image.NewRGBA(image.Rect(0, 0, 8, 8))
|
|
if err := png.Encode(&pngBuf, img); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := jpeg.Encode(&jpgBuf, img, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
webpData, err := base64.StdEncoding.DecodeString(tinyWebPBase64)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
data []byte
|
|
contentType string
|
|
wantErr bool
|
|
}{
|
|
{"png", pngBuf.Bytes(), "image/png", false},
|
|
{"jpeg", jpgBuf.Bytes(), "image/jpeg", false},
|
|
{"webp", webpData, "image/webp", false},
|
|
{"不是图片", []byte("这不是图片"), "text/html", true},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
src, err := decodeAvatar(c.data, c.contentType)
|
|
if c.wantErr {
|
|
if err == nil {
|
|
t.Fatal("应该报错")
|
|
}
|
|
if !bytes.Contains([]byte(err.Error()), []byte(c.contentType)) {
|
|
t.Errorf("错误信息里应该带上 Content-Type 方便排查:%v", err)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("解码失败:%v", err)
|
|
}
|
|
if src == nil {
|
|
t.Fatal("解出来是空的")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 解出来的头像要能走通圆形裁切 + 缓存用的 PNG 编码(gif 是 *image.Paletted,和 jpg/png 的底层类型都不同)
|
|
func TestDecodeAvatarGifGoesThroughCircleAvatar(t *testing.T) {
|
|
pal := color.Palette{color.RGBA{G: 255, A: 255}, color.RGBA{R: 255, B: 255, A: 255}}
|
|
frame := image.NewPaletted(image.Rect(0, 0, 16, 16), pal)
|
|
var buf bytes.Buffer
|
|
if err := gif.EncodeAll(&buf, &gif.GIF{Image: []*image.Paletted{frame}, Delay: []int{10}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
src, err := decodeAvatar(buf.Bytes(), "image/gif")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var out bytes.Buffer
|
|
if err := png.Encode(&out, circleAvatar(src, avatarCacheSize)); err != nil {
|
|
t.Fatalf("圆形裁切后的 PNG 编码失败:%v", err)
|
|
}
|
|
if out.Len() == 0 {
|
|
t.Fatal("编码出来是空的")
|
|
}
|
|
}
|