合并先在本地做完再落到成片目录;头像支持 gif 和静态 webp

起因是核查合并落盘和头像处理时发现的两处问题:

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 干净。
This commit is contained in:
tamakyi
2026-09-25 17:34:31 +08:00
parent eb250bb842
commit e35a4f7cef
9 changed files with 377 additions and 10 deletions
+3 -1
View File
@@ -114,6 +114,8 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
录播原片和弹幕文件(`.ass`、`.nick.ass`、`.jsonl`)保存在保存目录下的`recordSubdir`子文件夹里(默认`record`),两者同名配对才能自动合并;合并后的成片保存在`mergeSubdir`子文件夹里(默认`merged`),留空则用默认值,两个名字不能相同。如果设置了`mergeOutputDir`,成片优先移动到那里。
合成**先在录播文件夹里做完,写完了才落到成片文件夹**(同一个盘时是直接改名,几乎瞬时;跨盘会自动拷贝):成片文件夹是网络盘、外接盘或者中途断线时,不会在里面留下一个写到一半、看起来却像正常成片的文件。合成失败或中途强杀程序时,临时文件(`acfunlive_chunk_*`、`*.acfunlive.part`)不会出现在文件管理的列表里,程序下次启动会自动清掉。
`recordFormat`决定录播原片的封装格式,默认为`mp4`(和原版`config.Output`的默认值一致),也可以改成`flv`、`ts`、`mkv`,在 web UI 的`录制`分区用下拉框修改。录制始终是`-c copy`直接封装直播源、不重新编码,所以四种格式的**画质和码率完全一样,区别只在容器**。
正常停止(web UI 的停止按钮、`stoprecord`、`Ctrl+C`、`quit`)会先向 ffmpeg 发`q`让它正常收尾,**四种格式都能正常播放**——本机 ffmpeg 6.0 实测录 5 秒后发`q`:mp4 161 KB、flv 163 KB、ts 191 KB、mkv 160 KB,全部可读且时长 5.14 秒。但如果进程被强杀(任务管理器结束进程、断电、系统崩溃),差别就很大了——同样录制 8 秒后强杀:
@@ -197,7 +199,7 @@ acfunlive -danmuburn xxx.jsonl -video xxx.flv -ffmpeg D:\tools\ffmpeg.exe
`-danmuburn`的弹幕文件支持`.jsonl`和`.ass`两种。给的是`.ass`时,如果同目录下已经有同名的非空`.jsonl`就直接用,否则按 ASS 里的`PlayResX`/`PlayResY`、字号和`\move`弹道把弹幕还原成 jsonl,并把还原结果写回弹幕文件旁边,下次再合就不用重新解析。`-out`不指定时输出到弹幕文件同目录,重名会自动加`(2)`之类的后缀,不会覆盖已有文件。ffmpeg 的路径除了`-ffmpeg`参数,也会读设置文件里的`ffmpegPath`;填相对路径时按程序所在目录解析,也可以填它所在的文件夹。
头像会按 uid 缓存到录制文件夹下的`avatar_cache`里,圆形裁切后的直径与弹幕文字的墨迹高度一致。上游原版的`.ass`里没有头像链接(`nick.ass`只有昵称和 uid),遇到这种情况会按 uid 向 AcFun 的`userInfo`接口反查头像,查不到的只烧文字。反查走网络且有超时,所以离线环境下合并会退化成纯文字弹幕并给出警告。
头像会按 uid 缓存到录制文件夹下的`avatar_cache`里,圆形裁切后的直径与弹幕文字的墨迹高度一致。头像图片支持 jpg、png、gif 和静态 webp:gif(包括多帧动图)取第一帧来裁切,动画 webp 解不开、遇到就只烧文字。上游原版的`.ass`里没有头像链接(`nick.ass`只有昵称和 uid),遇到这种情况会按 uid 向 AcFun 的`userInfo`接口反查头像,查不到的只烧文字。反查走网络且有超时,所以离线环境下合并会退化成纯文字弹幕并给出警告。
有 Intel 核显时优先用 QSV 硬件编码(`h264_qsv`),不可用或运行失败时自动回退到`libx264`。
+12 -4
View File
@@ -252,19 +252,22 @@ func autoMerge(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int
return
}
// 成片直接输出到成片文件夹,不再事后再移动一次
// 成片先在本地合成,再落到成片文件夹:成片目录可能是网络盘或外接盘,
// 直接往里写、写到一半失败会在那里留下一个看起来正常的半成品
mergeDir := mergeSaveDir()
if err := os.MkdirAll(mergeDir, 0755); err != nil {
lPrintWarnf("创建成片文件夹 %s 失败:%v", mergeDir, err)
return
}
outFile := filepath.Join(mergeDir, filepath.Base(strings.TrimSuffix(jsonlFile, ".jsonl")+"_弹幕合并.mp4"))
tmpOut := tempMergeOutput(jsonlFile, filepath.Base(outFile))
// 标记成正在写入,避免在 web UI 的文件管理里把还没写完的成片删掉
markFileActive(outFile, true)
burnErr := burnDanmu(jsonl, video, outFile, offset)
markFileActive(outFile, false)
markFileActive(tmpOut, true)
burnErr := burnDanmu(jsonl, video, tmpOut, offset)
markFileActive(tmpOut, false)
if burnErr != nil {
_ = os.Remove(tmpOut)
if burnErr == errNoDanmu {
lPrintWarn("弹幕文件里没有弹幕,跳过自动合并")
return
@@ -272,6 +275,11 @@ func autoMerge(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int
lPrintErrf("自动合并失败:%v", burnErr)
return
}
if err := placeOutput(tmpOut, outFile); err != nil {
_ = os.Remove(tmpOut)
lPrintErrf("自动合并失败:%v", err)
return
}
lPrintln("自动合并完成:" + outFile)
sendNotify(notifyEventMergeDone, uid, nameForLog(uid), "", "", outFile,
+111
View File
@@ -0,0 +1,111 @@
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("编码出来是空的")
}
}
+3
View File
@@ -406,6 +406,9 @@ func checkConfig() {
os.Exit(1)
}
}
// 上次在合成途中被关掉的话,录播目录和成片目录里会留下临时文件
cleanTempFiles(recordSaveDir())
cleanTempFiles(mergeSaveDir())
for _, u := range config.UIDs {
if u.Bitrate < 0 {
lPrintErrf("%s里的uid %d的bitrate不能小于 0", configFile, u.UID)
+15 -2
View File
@@ -11,6 +11,7 @@ import (
"image"
"image/color"
"image/draw"
_ "image/gif"
_ "image/jpeg"
"image/png"
"io"
@@ -26,6 +27,7 @@ import (
"time"
xdraw "golang.org/x/image/draw"
_ "golang.org/x/image/webp"
)
// 头像缓存目录名
@@ -687,9 +689,9 @@ func downloadAvatar(uid int64, url, path string) error {
if err != nil {
return err
}
src, _, err := image.Decode(bytes.NewReader(data))
src, err := decodeAvatar(data, resp.Header.Get("Content-Type"))
if err != nil {
return fmt.Errorf("解析失败(可能是 webp 格式):%v", err)
return err
}
out, err := os.Create(path)
@@ -700,6 +702,17 @@ func downloadAvatar(uid int64, url, path string) error {
return png.Encode(out, circleAvatar(src, avatarCacheSize))
}
// 解码头像图片。AcFun 的头像里混着 gif 和 webp:
// gif 由标准库解码,多帧的取第一帧;webp 只支持静态的,动画 webp 会报错,
// 这种情况下这个用户就不烧头像,只烧文字。
func decodeAvatar(data []byte, contentType string) (image.Image, error) {
src, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("图片解析不了(Content-Type: %s):%v", contentType, err)
}
return src, nil
}
// uid → 头像链接的记忆,包含查不到的结果,避免同一个 uid 反复去问
var avatarURLMemo struct {
sync.Mutex
+4
View File
@@ -111,6 +111,10 @@ func listFiles(dir string) []fileEntry {
if e.IsDir() {
continue
}
// 合成过程中的临时文件(拼接、分块、本地临时成片)不上列表
if strings.HasPrefix(e.Name(), chunkTempPrefix) || strings.HasSuffix(e.Name(), partSuffix) {
continue
}
info, err := e.Info()
if err != nil {
continue
+12 -3
View File
@@ -470,17 +470,26 @@ func runManualMerge(plan mergePlan) error {
if _, err := os.Stat(plan.out); err == nil {
lPrintWarn("成片已存在,将被覆盖:" + plan.out)
}
// 先在本地(弹幕所在的录播目录)合成,完成后再落到成片目录:
// 成片目录可能是网络盘或外接盘,直接往里写、写到一半失败会在那里留个半成品
tmpOut := tempMergeOutput(plan.danmu, filepath.Base(plan.out))
lPrintf("手动合并开始:视频 %s,弹幕 %s,偏移 %.3f 秒(%s)",
filepath.Base(video), filepath.Base(jsonl), offset, note)
lPrintf("先在本地合成 %s,完成后落到 %s", filepath.Base(tmpOut), plan.out)
// 标记成正在写入,避免在文件管理里把还没写完的成片删掉
markFileActive(plan.out, true)
burnErr := burnDanmu(jsonl, video, plan.out, offset)
markFileActive(plan.out, false)
markFileActive(tmpOut, true)
burnErr := burnDanmu(jsonl, video, tmpOut, offset)
markFileActive(tmpOut, false)
if burnErr != nil {
_ = os.Remove(tmpOut)
return burnErr
}
if err := placeOutput(tmpOut, plan.out); err != nil {
_ = os.Remove(tmpOut)
return err
}
lPrintln("手动合并完成:" + plan.out)
return nil
+137
View File
@@ -0,0 +1,137 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// 合成好的本地临时文件落到成片目录:同盘走改名,本地不该再留文件
func TestPlaceOutputMovesFile(t *testing.T) {
setupMergeTest(t)
tmp := filepath.Join(recordSaveDir(), chunkTempPrefix+"merging_测试_弹幕合并.mp4")
writeTestFile(t, tmp, "成片内容")
out := filepath.Join(mergeSaveDir(), "测试_弹幕合并.mp4")
if err := placeOutput(tmp, out); err != nil {
t.Fatalf("落盘失败:%v", err)
}
got, err := os.ReadFile(out)
if err != nil || string(got) != "成片内容" {
t.Fatalf("成片内容不对:%q %v", got, err)
}
if _, err := os.Stat(tmp); !os.IsNotExist(err) {
t.Error("落盘之后本地临时文件应该已经不在了")
}
}
// 成片目录里的子目录不存在时自动创建,已有同名成片时直接覆盖
func TestPlaceOutputCreatesDirAndOverwrites(t *testing.T) {
setupMergeTest(t)
sub := filepath.Join(mergeSaveDir(), "按日期", "2026-09")
if err := os.MkdirAll(sub, 0755); err != nil {
t.Fatal(err)
}
out := filepath.Join(sub, "同名_弹幕合并.mp4")
writeTestFile(t, out, "上一次的成片")
tmp := filepath.Join(recordSaveDir(), chunkTempPrefix+"merging_同名_弹幕合并.mp4")
writeTestFile(t, tmp, "这一次的成片")
if err := placeOutput(tmp, out); err != nil {
t.Fatalf("落盘失败:%v", err)
}
got, err := os.ReadFile(out)
if err != nil || string(got) != "这一次的成片" {
t.Fatalf("应该覆盖成新内容,实际:%q %v", got, err)
}
// 成片目录里不该留 .part
entries, _ := os.ReadDir(sub)
for _, e := range entries {
if strings.HasSuffix(e.Name(), partSuffix) {
t.Errorf("不该留下 %s", e.Name())
}
}
}
// 本地临时文件不存在时报错,而且不在成片目录留下任何东西
func TestPlaceOutputMissingSourceLeavesNothing(t *testing.T) {
setupMergeTest(t)
out := filepath.Join(mergeSaveDir(), "没有源文件_弹幕合并.mp4")
if err := placeOutput(filepath.Join(recordSaveDir(), "不存在.mp4"), out); err == nil {
t.Fatal("源文件不存在时应该报错")
}
entries, err := os.ReadDir(mergeSaveDir())
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if e.Name() == filepath.Base(out) || strings.HasSuffix(e.Name(), partSuffix) {
t.Errorf("失败之后不该留下 %s", e.Name())
}
}
}
// 临时成片放在弹幕文件所在目录(本地),且带临时前缀(否则会出现在文件管理里)
func TestTempMergeOutputPath(t *testing.T) {
dir := t.TempDir()
danmu := filepath.Join(dir, "record", "2026-09-25 01-00-00 甲.jsonl")
got := tempMergeOutput(danmu, "2026-09-25 01-00-00 甲_弹幕合并.mp4")
want := filepath.Join(dir, "record", chunkTempPrefix+"merging_2026-09-25 01-00-00 甲_弹幕合并.mp4")
if got != want {
t.Errorf("临时路径不对:\n got %s\nwant %s", got, want)
}
if !strings.HasPrefix(filepath.Base(got), chunkTempPrefix) {
t.Error("临时文件必须带临时前缀,否则会被当成正常文件列出")
}
}
// 启动时清理残留的临时文件,正常的录播 / 成片不动
func TestCleanTempFiles(t *testing.T) {
setupMergeTest(t)
staleRecord := []string{
filepath.Join(recordSaveDir(), chunkTempPrefix+"joined.mp4"),
filepath.Join(recordSaveDir(), chunkTempPrefix+"remapped.jsonl"),
filepath.Join(recordSaveDir(), chunkTempPrefix+"merging_甲_弹幕合并.mp4"),
}
staleMerge := filepath.Join(mergeSaveDir(), "甲_弹幕合并.mp4"+partSuffix)
for _, p := range append(append([]string{}, staleRecord...), staleMerge) {
writeTestFile(t, p, "残留")
}
keep := []string{
writeTestFile(t, filepath.Join(recordSaveDir(), "2026-09-25 01-00-00 甲.jsonl"), "弹幕"),
writeTestFile(t, filepath.Join(recordSaveDir(), "2026-09-25 01-00-00 甲.mp4"), "视频"),
writeTestFile(t, filepath.Join(mergeSaveDir(), "2026-09-25 01-00-00 甲_弹幕合并.mp4"), "成片"),
}
cleanTempFiles(recordSaveDir())
cleanTempFiles(mergeSaveDir())
for _, p := range append(append([]string{}, staleRecord...), staleMerge) {
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Errorf("残留的临时文件应该被清掉:%s", filepath.Base(p))
}
}
for _, p := range keep {
if _, err := os.Stat(p); err != nil {
t.Errorf("正常文件不该被删:%s", filepath.Base(p))
}
}
}
// 临时文件不出现在文件管理的列表里
func TestListFilesHidesTemp(t *testing.T) {
setupMergeTest(t)
writeTestFile(t, filepath.Join(recordSaveDir(), "2026-09-25 01-00-00 甲.mp4"), "视频")
writeTestFile(t, filepath.Join(recordSaveDir(), chunkTempPrefix+"merging_甲_弹幕合并.mp4"), "临时")
writeTestFile(t, filepath.Join(recordSaveDir(), "甲_弹幕合并.mp4"+partSuffix), "半成品")
names := make([]string, 0, 4)
for _, f := range listFiles(recordSaveDir()) {
names = append(names, f.Name)
}
if len(names) != 1 || names[0] != "2026-09-25 01-00-00 甲.mp4" {
t.Errorf("列表里应该只剩正常录播文件,实际:%v", names)
}
}
+80
View File
@@ -7,7 +7,9 @@ import (
"io"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
@@ -257,3 +259,81 @@ func isRecording(liveID string) bool {
}
return false
}
// 跨盘搬运成片时的临时后缀:拷一半失败也能一眼看出不是成品
const partSuffix = ".acfunlive.part"
// 合成时的临时成片放在弹幕文件所在目录(本地),
// 完成后再落到成片目录 —— 成片目录可能是网络盘或外接盘,直接往里写、
// 写到一半失败会在那里留下一个看起来正常的半成品。文件名沿用临时前缀,
// 所以不会出现在文件管理的列表里,启动时也会清理残留。
func tempMergeOutput(danmuFile, outBase string) string {
return filepath.Join(filepath.Dir(danmuFile), chunkTempPrefix+"merging_"+outBase)
}
// 把合成好的本地临时文件落到成片位置:同盘直接改名(瞬时),跨盘退回拷贝
func placeOutput(tmp, out string) error {
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
return fmt.Errorf("创建成片文件夹失败:%v", err)
}
if err := os.Rename(tmp, out); err == nil {
return nil
}
// 跨盘时 os.Rename 会失败(Windows 上尤其明确),退回拷贝:
// 先拷成 .part 再改名,中途失败也不会在成片目录留下像成品的文件
part := out + partSuffix
if err := copyFile(tmp, part); err != nil {
_ = os.Remove(part)
return fmt.Errorf("拷贝到成片目录 %s 失败:%v", filepath.Dir(out), err)
}
if err := os.Rename(part, out); err != nil {
_ = os.Remove(part)
return fmt.Errorf("成片改名失败:%v", err)
}
_ = os.Remove(tmp)
return nil
}
// 拷贝文件,跨盘搬运成片时用
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
return out.Close()
}
// 清掉上次运行残留的合成临时文件。正常结束时都会删掉,这里是兜底:
// 程序在合成中途被强杀时,临时文件会留在录播目录或成片目录里。
// 注意强杀不会连带杀掉 ffmpeg 子进程,它可能还在写这个文件(Windows 上就删不掉),
// 所以删不掉时只提示一句,下次启动会再清一次。
func cleanTempFiles(dir string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasPrefix(name, chunkTempPrefix) && !strings.HasSuffix(name, partSuffix) {
continue
}
path := filepath.Join(dir, name)
if err := os.Remove(path); err == nil {
lPrintln("清理上次留下的临时文件:" + name)
} else {
lPrintWarnf("临时文件 %s 现在删不掉(多半是上次强杀程序后残留的 ffmpeg 还在写),下次启动会自动清理:%v", path, err)
}
}
}