diff --git a/README.md b/README.md index cd6070d..9b337d0 100644 --- a/README.md +++ b/README.md @@ -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`。 diff --git a/automerge.go b/automerge.go index a9117f2..e03822e 100644 --- a/automerge.go +++ b/automerge.go @@ -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, diff --git a/avatar_test.go b/avatar_test.go new file mode 100644 index 0000000..bbff9f7 --- /dev/null +++ b/avatar_test.go @@ -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("编码出来是空的") + } +} diff --git a/config.go b/config.go index 1c6997a..6ac6f12 100644 --- a/config.go +++ b/config.go @@ -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) diff --git a/danmuburn.go b/danmuburn.go index 8fa94f8..ef793c5 100644 --- a/danmuburn.go +++ b/danmuburn.go @@ -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 diff --git a/files.go b/files.go index ff4da4c..cc63a68 100644 --- a/files.go +++ b/files.go @@ -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 diff --git a/manualmerge.go b/manualmerge.go index e9e5d10..c9d9f01 100644 --- a/manualmerge.go +++ b/manualmerge.go @@ -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 diff --git a/output_test.go b/output_test.go new file mode 100644 index 0000000..d16d90d --- /dev/null +++ b/output_test.go @@ -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) + } +} diff --git a/utils.go b/utils.go index 923bdc8..e216540 100644 --- a/utils.go +++ b/utils.go @@ -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) + } + } +}