1. 密集弹幕时头像偏离轨迹、弹幕突然加速、之后一段时间屏幕空白
分块渲染的固有问题,三个毛病叠在一起:
- writeChunkAss 把 \move 的起止时间截断到块边界(End=min(t0+10, chunk.end)),
但 x2 写死成 -Width —— 等于把 10 秒的路挤进可见的那零点几秒里,文字直接飞过去。
而头像 overlay 的 x 一直是「固定 /10 秒 + 真实 t0」,于是同一时刻文字已经走到
左边、头像还在半路,看起来就是「头像掉队(移动变慢)」。
- 块尾还会被单块上限收紧成「下一条弹幕出现的时间」,最极端时窗口只剩 0.01 秒。
- 每块只画「本块新出现」的弹幕,跨块的续画没有:块头屏幕上只剩刚出现的几条,
要 10 秒才重新填满 —— 就是「弹幕突然加速,然后一段时间屏幕上什么都没有」。
改法:文字层改成按绝对时间算轨迹(danmuTextX / danmuAvatarX / avatarXExpr 同源),
任何一块里的文字速度都等于真实速度;分块器改成「续画(起播时间在 start-10 ~ start)
+ 本块新出现的」,块尾取「下一条弹幕的起播时间」,这样每条弹幕只在唯一一块里算
新出现,显示期间跨过的后面几块把它接着画,一帧都不缺。
代价是每块要多画一倍多的弹幕,所以顺手把头像一个「绝对路径 + 每人一行 scale」改成
「预缩放 + 短相对路径」(prepareAvatarInputs / scaleAvatarFile),每条从实测 241
字符降到约 130,单块上限才能从 120 抬到 200(200 条 ≈ 26600,仍低于自检阈值 30000
和 Windows 的 32767)。
2. 合并失败没有任何通知
notify.go 新增 mergeFail 事件与 notifyMergeFailure(),接在 autoMerge() 和手动合并的
失败分支上。正常结束、文件里没有弹幕、以及用户自己点的停止都不算失败,不推 ——
最后一种推「合并失败」会误导。配置项 notifyMergeFail 默认开,且和 notifyEnabled 一样
在 Unmarshal 之前预置 true,免得老设置文件升级后被静默关掉。
3. 低帧率录播里的弹幕一格一格跳
新增设置项 mergeFps,默认 0 = 跟随源片(什么都不做)。只在目标高于源片帧率时才加
fps 滤镜,而且必须加在字幕/头像之前 —— 加在之后只是把已经画好的几帧复制一遍,
看起来还是卡。上限 240。
4. 顺带
- -danmuburn 命令行模式不走 initialize(),exeDir 从来没被赋值:头像缓存会落到
「当前工作目录/avatar_cache」,旧版还会把这个相对路径写进滤镜,而 ffmpeg 的工作
目录是弹幕目录,报「打不开 avatar_cache\xxx.png」整块失败。
- cleanTempFiles 见到目录就 continue,预缩放头像那个临时文件夹永远清不掉。
- command_len_test 不再用会过期的「每条 241 字符」估算,改成真跑一遍最坏情况;
探针的头像路径也改成真实的短相对路径(原来量的是早已不存在的长绝对路径)。
- 新增 TestWebuiSettingFieldsMatchInputs:把页面里 JS 的 settingFields/numberFields
抠出来,跟所有 input/select/textarea 的 id 比对,漏登记的设置项会被抓住。
验证
- gofmt / go vet / go test(174 个)/ go test -race / staticcheck / GOOS=linux go build 全过。
- 反向验证 32 项全过:把每个修复临时退回,对应测试确实失败。
- 真实片段实测(2023AcFun 晚会 120 秒高峰段,689 条弹幕,10 秒窗口内最多同时 140 条):
旧 6 块 / 绘制 689 次 / 54.1% 的弹幕文字被加速(中位 2.12x,最差 500x)/
54.2% 有轨迹缺口(中位缺 5.29 秒)/ 78.3s;
新 5 块 / 绘制 984 次 / 加速 0% / 缺口 0% / 98.8s(+26%)。
对照帧里旧版在块尾前 0.1 秒屏幕已经全空,块头后 0.4 秒仍然全空;新版一直是满屏。
- 输出帧率:15fps 源配 mergeFps=60,成片 60fps,1 秒内 60 帧哈希互不相同(不是复制帧);
默认设置下成片仍是 15fps。
155 lines
5.6 KiB
Go
155 lines
5.6 KiB
Go
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"), "成片"),
|
|
}
|
|
// 预缩放头像是个目录,也要一起清掉;别顺手把普通的录播子目录当成临时文件夹删了
|
|
avatarDir := filepath.Join(recordSaveDir(), chunkAvatarDir)
|
|
if err := os.MkdirAll(avatarDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeTestFile(t, filepath.Join(avatarDir, "1.png"), "头像")
|
|
normalDir := filepath.Join(recordSaveDir(), "别的文件夹")
|
|
if err := os.MkdirAll(normalDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cleanTempFiles(recordSaveDir())
|
|
cleanTempFiles(mergeSaveDir())
|
|
|
|
if _, err := os.Stat(avatarDir); !os.IsNotExist(err) {
|
|
t.Errorf("残留的预缩放头像文件夹应该被整个清掉:%s", chunkAvatarDir)
|
|
}
|
|
if _, err := os.Stat(normalDir); err != nil {
|
|
t.Errorf("名字不带临时前缀的目录不该被删:%s", filepath.Base(normalDir))
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|