合并默认改用 HEVC、码率按「源片 + 500」自动算;手动合并自动匹配同名弹幕

两件事:

1) 合成码率与编码器
- `mergeBitrate` 填 0(默认)时不再是 ICQ 质量模式,改成「源片视频码率 + 500 Kbps」:
  烧进去的文字和头像都是高频细节,和源片同码率会糊,所以留一点余量;探测不到源片
  码率时才退回质量模式(QSV 用 ICQ 22、软编用 CRF 18),下限 800 Kbps。
- 编码器默认换成硬件 HEVC:候选顺序 hevc_qsv → h264_qsv → libx264,QSV 可用性按
  编码器分别探测(原来只探 h264_qsv)。HEVC 在 mp4 里打 `hvc1` tag,Apple 系
  (QuickTime / Safari / iPhone)才认 —— ffmpeg 默认写的是 hev1。
- 日志现在会打印编码器优先顺序和实际用的码率。

  实测(90 秒真实素材,源片 3217 kbps,密集弹幕):日志「按源片 3217 kbps + 弹幕
  余量 500 kbps 取 3717 kbps」,成片 codec=hevc、tag=hvc1、实际 3.71 Mbps。
  之前同样是这份素材:h264 + ICQ22 会飙到 7386 kbps(79 MB),改成自动码率后
  43.7 MB,体积小了 45% 画质还更稳。

2) 手动合并自动匹配弹幕
- 新增 `GET /api/merge/danmu?video=…`:按视频名找同目录下的同名弹幕,依次试
  `.jsonl`、`.ass`、`.nick.ass`;同目录没有就拿文件名去录播目录再找一次。
- 页面在「手填/粘贴视频后失焦」「多选弹窗选完」「从文件列表多选后」「点预览或
  开始合并时弹幕框还空着」这几种情况下都会自动匹配并提示;弹幕框里已经有内容时
  绝不动它,找不到也保持为空让用户自己选。
- 填回框里的写法与浏览弹窗一致:录播/成片目录下的只写文件名,别处的写绝对路径。

测试:新增 encode_test.go(编码器顺序、参数与 hvc1、自动码率、ffmpeg 输出解析)
和 TestFindDanmuForVideo(7 个子用例:优先级、录播目录回退、绝对路径、找不到返回空);
仓库外的 verify_danmu_match.py 4 组断言全过,verify_pick.py / verify_settings.py 重跑
无回归;go test ./...、gofmt、go vet 干净。
This commit is contained in:
tamakyi
2026-09-25 19:23:44 +08:00
parent e8b455dfe8
commit a891094f29
10 changed files with 435 additions and 76 deletions
+116
View File
@@ -718,3 +718,119 @@ Dialogue: 0,0:00:05.00,0:00:15.00,Danmu,乙(202),0,0,0,,{\move(400,48,-40,48)}
}
t.Logf("多段成片 %s(%.2f 秒)", filepath.Base(plan.out), d)
}
// 手动合并:按视频文件名自动找同目录下的同名弹幕文件
func TestFindDanmuForVideo(t *testing.T) {
savedConfig := config
oldRecordDir := recordDir
savedMerge := config.MergeOutputDir
defer func() {
config = savedConfig
recordDir = oldRecordDir
config.MergeOutputDir = savedMerge
}()
// 每个用例一套独立的目录环境
prepare := func(t *testing.T) (string, string) {
t.Helper()
dir := t.TempDir()
config.Directory = dir
config.RecordSubdir = defaultRecordSubdir
config.MergeSubdir = defaultMergeSubdir
config.MergeOutputDir = ""
if recordDir == nil {
recordDir = new(string)
}
*recordDir = dir
recDir := recordSaveDir()
if err := os.MkdirAll(recDir, 0755); err != nil {
t.Fatal(err)
}
return dir, recDir
}
t.Run("优先用 jsonl", func(t *testing.T) {
_, recDir := prepare(t)
writeTestFile(t, filepath.Join(recDir, "a.mp4"), "v")
writeTestFile(t, filepath.Join(recDir, "a.ass"), "ass")
writeTestFile(t, filepath.Join(recDir, "a.jsonl"), "jsonl")
// 录播目录里的只填文件名,方便以后改保存目录
if got := findDanmuForVideo("a.mp4"); got != filepath.Join(recDir, "a.jsonl") {
t.Errorf("应优先 .jsonl,实际 %s", got)
}
if got := mergeFillValue(filepath.Join(recDir, "a.jsonl")); got != "a.jsonl" {
t.Errorf("录播目录里的应只写文件名,实际 %s", got)
}
})
t.Run("没有 jsonl 时用 ass", func(t *testing.T) {
_, recDir := prepare(t)
writeTestFile(t, filepath.Join(recDir, "b.mp4"), "v")
writeTestFile(t, filepath.Join(recDir, "b.ass"), "ass")
if got := findDanmuForVideo("b.mp4"); got != filepath.Join(recDir, "b.ass") {
t.Errorf("应回退到 .ass,实际 %s", got)
}
})
t.Run("只有昵称头像版 ass 也能认", func(t *testing.T) {
_, recDir := prepare(t)
writeTestFile(t, filepath.Join(recDir, "c.mp4"), "v")
writeTestFile(t, filepath.Join(recDir, "c.nick.ass"), "ass")
if got := findDanmuForVideo("c.mp4"); got != filepath.Join(recDir, "c.nick.ass") {
t.Errorf("应认 .nick.ass,实际 %s", got)
}
})
t.Run("视频在别处时用同目录的弹幕并填绝对路径", func(t *testing.T) {
_, _ = prepare(t)
other := t.TempDir()
video := filepath.Join(other, "d.mp4")
writeTestFile(t, video, "v")
writeTestFile(t, filepath.Join(other, "d.jsonl"), "jsonl")
got := findDanmuForVideo(video)
if got != filepath.Join(other, "d.jsonl") {
t.Errorf("应找到同目录的弹幕,实际 %s", got)
}
if fill := mergeFillValue(got); fill != got {
t.Errorf("不在录播/成片目录时应填绝对路径,实际 %s", fill)
}
})
t.Run("同目录没有就看录播目录", func(t *testing.T) {
_, recDir := prepare(t)
writeTestFile(t, filepath.Join(recDir, "e.jsonl"), "jsonl")
other := t.TempDir()
video := filepath.Join(other, "e.mp4")
writeTestFile(t, video, "v")
got := findDanmuForVideo(video)
if got != filepath.Join(recDir, "e.jsonl") {
t.Errorf("应回退到录播目录,实际 %s", got)
}
if fill := mergeFillValue(got); fill != "e.jsonl" {
t.Errorf("录播目录里的应只写文件名,实际 %s", fill)
}
})
t.Run("找不到就返回空", func(t *testing.T) {
_, recDir := prepare(t)
writeTestFile(t, filepath.Join(recDir, "f.mp4"), "v")
if got := findDanmuForVideo("f.mp4"); got != "" {
t.Errorf("没有同名弹幕时应返回空,实际 %s", got)
}
if got := findDanmuForVideo("不存在的视频.mp4"); got != "" {
t.Errorf("视频本身找不到时应返回空,实际 %s", got)
}
})
t.Run("成片目录里的也写文件名", func(t *testing.T) {
dir, _ := prepare(t)
mergeDir := mergeSaveDir()
if err := os.MkdirAll(mergeDir, 0755); err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(mergeDir, "g.jsonl"), "jsonl")
if fill := mergeFillValue(filepath.Join(dir, config.MergeSubdir, "g.jsonl")); fill != "g.jsonl" {
t.Errorf("成片目录里的应只写文件名,实际 %s", fill)
}
})
}