合并默认改用 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
+127 -38
View File
@@ -208,11 +208,14 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
lPrintf("弹幕字号按设置使用 %d(弹幕文件里是 %d)", config.DanmuFontSize, meta.FontSize)
meta.FontSize = config.DanmuFontSize
}
// 弹幕文件的坐标系和视频对不上时,统一到视频坐标系(见 unifyDanmuSpace)
// 弹幕文件的坐标系和视频对不上时,统一到视频坐标系(见 unifyDanmuSpace);
// 顺便拿到源片码率,用于「合成码率填 0」时的自动码率
sourceKbps := 0
if burnMode {
if vw, vh := probeVideoSize(ffmpegFile, videoFile); vw > 0 && vh > 0 {
unifyDanmuSpace(&meta, vw, vh, config.DanmuFontSize)
}
var vw, vh int
vw, vh, sourceKbps = probeVideoStream(ffmpegFile, videoFile)
unifyDanmuSpace(&meta, vw, vh, config.DanmuFontSize)
lPrintf("视频编码器优先顺序:%s", strings.Join(availableEncoders(ffmpegFile), " → "))
}
// 弹道按最终字号统一重排,避免还原弹幕或改字号后同弹道追尾
relayoutLanes(records, meta)
@@ -262,6 +265,17 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
lPrintWarn("没有任何头像可用,弹幕将只烧录文字,请检查网络后重试合并")
}
// 合成码率:填了值就用它;填 0(默认)按「源片码率 + 弹幕余量」算,探测不到才退回质量模式
bitrate := config.MergeBitrate + burnBitrate
if bitrate == 0 && burnMode {
if auto := autoMergeBitrate(sourceKbps); auto > 0 {
lPrintf("合成码率填的 0,按源片 %d kbps + 弹幕余量 %d kbps 取 %d kbps", sourceKbps, danmuBitrateMargin, auto)
bitrate = auto
} else {
lPrintWarn("探测不到源片码率,改用默认质量模式(ICQ 22 / CRF 18)")
}
}
ctx := &burnContext{
ffmpegFile: ffmpegFile,
videoFile: videoFile,
@@ -269,7 +283,7 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
offset: offset,
meta: meta,
avatars: avatarPaths,
bitrate: config.MergeBitrate + burnBitrate,
bitrate: bitrate,
dir: dir,
}
@@ -361,30 +375,66 @@ func unifyDanmuSpace(meta *danmuMeta, vw, vh, fontSizeOverride int) bool {
return true
}
// 用 ffmpeg 探测视频分辨率,探测不到返回 0, 0
func probeVideoSize(ffmpegFile, path string) (int, int) {
// 探测视频流的分辨率和码率(Kbps),拿不到的项返回 0
func probeVideoStream(ffmpegFile, path string) (int, int, int) {
cmd := exec.Command(ffmpegFile, "-hide_banner", "-i", path)
var buf bytes.Buffer
cmd.Stderr = &buf
// 没指定输出文件时 ffmpeg 会返回非 0,但流信息已经打印出来了
_ = cmd.Run()
width, height, kbps := 0, 0, 0
for _, line := range strings.Split(buf.String(), "\n") {
// 容器的总码率(含音频)先记下来,视频流自己没写码率时用它
if strings.Contains(line, "Duration:") {
if m := containerBitrateRe.FindStringSubmatch(line); len(m) == 2 {
kbps, _ = strconv.Atoi(m[1])
}
continue
}
// 只看视频流,跳过封面图(mjpeg 之类的 attached pic)那几行
if !strings.Contains(line, "Video:") || strings.Contains(line, "attached pic") {
continue
}
if m := videoSizeRe.FindStringSubmatch(line); len(m) == 3 {
w, _ := strconv.Atoi(m[1])
h, _ := strconv.Atoi(m[2])
return w, h
width, _ = strconv.Atoi(m[1])
height, _ = strconv.Atoi(m[2])
}
if m := videoBitrateRe.FindStringSubmatch(line); len(m) == 2 {
kbps, _ = strconv.Atoi(m[1])
}
}
return 0, 0
return width, height, kbps
}
// 逗号后紧跟的「数字x数字」就是分辨率,前面的编码器名里也有 0x… 这种十六进制,靠逗号区分
var videoSizeRe = regexp.MustCompile(`, (\d{2,5})x(\d{2,5})[, \[]`)
var (
// 逗号后紧跟的「数字x数字」就是分辨率,前面的编码器名里也有 0x… 这种十六进制,靠逗号区分
videoSizeRe = regexp.MustCompile(`, (\d{2,5})x(\d{2,5})[, \[]`)
videoBitrateRe = regexp.MustCompile(`, (\d+) kb/s`)
// 容器总码率,形如「Duration: 00:02:55.07, start: 0.000000, bitrate: 1767 kb/s」
containerBitrateRe = regexp.MustCompile(`bitrate: (\d+) kb/s`)
)
// 合成码率填 0(默认)时的算法:源片码率 + 弹幕余量。
// 烧进去的文字和头像都是高频细节,跟源片同样码率会糊,所以留一点余量;
// 探测不到源片码率时返回 0,让调用方退回质量模式。
func autoMergeBitrate(sourceKbps int) int {
if sourceKbps <= 0 {
return 0
}
br := sourceKbps + danmuBitrateMargin
if br < minMergeBitrate {
br = minMergeBitrate
}
return br
}
const (
// 弹幕层占用的额外码率(Kbps)
danmuBitrateMargin = 500
// 自动码率的下限,源片码率特别低时也别低于这个值
minMergeBitrate = 800
)
// 用 ffmpeg 探测媒体文件的时长(秒),探测不到返回 0
func probeDuration(ffmpegFile, path string) float64 {
@@ -704,20 +754,56 @@ type videoEncoder struct {
opts []string
}
// 按目标码率构造编码器候选列表,QSV 可用时优先
func buildEncoders(bitrate int, ffmpegFile string) []videoEncoder {
var qOpts, xOpts []string
if bitrate > 0 {
br := strconv.Itoa(bitrate) + "k"
qOpts = []string{"-b:v", br}
xOpts = []string{"-b:v", br, "-preset", "medium"}
} else {
qOpts = []string{"-global_quality", "22", "-preset", "medium"}
xOpts = []string{"-crf", "18", "-preset", "medium"}
// 编码器候选顺序:硬件 HEVC(同画质体积最省)→ 硬件 H.264 → CPU 软编
func encoderCandidates(hasHEVCQSV, hasH264QSV bool) []string {
names := make([]string, 0, 3)
if hasHEVCQSV {
names = append(names, "hevc_qsv")
}
encoders := []videoEncoder{{"libx264", xOpts}}
if qsvAvailable(ffmpegFile) {
encoders = append([]videoEncoder{{"h264_qsv", qOpts}}, encoders...)
if hasH264QSV {
names = append(names, "h264_qsv")
}
return append(names, "libx264")
}
// 本机可用的编码器候选,顺序即优先级
func availableEncoders(ffmpegFile string) []string {
return encoderCandidates(qsvEncoderAvailable(ffmpegFile, "hevc_qsv"),
qsvEncoderAvailable(ffmpegFile, "h264_qsv"))
}
// 某个编码器的输出参数。bitrate 为 0 时用质量模式(ICQ / CRF)
func encoderOpts(encoder string, bitrate int) []string {
var opts []string
isQSV := strings.HasSuffix(encoder, "_qsv")
if bitrate > 0 {
opts = []string{"-b:v", strconv.Itoa(bitrate) + "k"}
if !isQSV {
opts = append(opts, "-preset", "medium")
}
} else if isQSV {
opts = []string{"-global_quality", "22", "-preset", "medium"}
} else {
opts = []string{"-crf", "18", "-preset", "medium"}
}
// HEVC 在 mp4 里默认写成 hev1,Apple 系(QuickTime / Safari / iPhone)不认,统一用 hvc1
if isHEVCCodec(encoder) {
opts = append(opts, "-tag:v", "hvc1")
}
return opts
}
// 这个编码器是不是 HEVC(hevc_qsv / libx265 / hevc_nvenc 之类)
func isHEVCCodec(encoder string) bool {
return strings.Contains(encoder, "hevc") || strings.Contains(encoder, "265")
}
// 按目标码率构造编码器候选列表(顺序即回退顺序)
func buildEncoders(bitrate int, ffmpegFile string) []videoEncoder {
names := availableEncoders(ffmpegFile)
encoders := make([]videoEncoder, 0, len(names))
for _, name := range names {
encoders = append(encoders, videoEncoder{name: name, opts: encoderOpts(name, bitrate)})
}
return encoders
}
@@ -895,31 +981,34 @@ func fetchAvatarURL(uid int64) string {
return strings.TrimSpace(out.Profile.HeadURL)
}
// QSV 可用性的探测结果,按 ffmpeg 路径缓存,避免分块时每块都试一次
// QSV 编码器的可用性探测结果,按 ffmpeg 路径缓存,避免分块时每块都试一次
var qsvCache struct {
sync.Mutex
path string
ok bool
done bool
path string
avail map[string]bool
}
// 探测 Intel QSV(h264_qsv)硬件编码是否可用
func qsvAvailable(ffmpegFile string) bool {
// 探测某个 Intel QSV 编码器(hevc_qsv / h264_qsv)能不能跑,做一次真实的极小编码
func qsvEncoderAvailable(ffmpegFile, encoder string) bool {
qsvCache.Lock()
if qsvCache.done && qsvCache.path == ffmpegFile {
ok := qsvCache.ok
qsvCache.Unlock()
return ok
if qsvCache.avail != nil && qsvCache.path == ffmpegFile {
if ok, tested := qsvCache.avail[encoder]; tested {
qsvCache.Unlock()
return ok
}
}
qsvCache.Unlock()
cmd := exec.Command(ffmpegFile, "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "color=c=black:s=320x240:d=0.5:r=30",
"-c:v", "h264_qsv", "-frames:v", "5", "-f", "null", "-")
"-c:v", encoder, "-frames:v", "5", "-f", "null", "-")
ok := cmd.Run() == nil
qsvCache.Lock()
qsvCache.path, qsvCache.ok, qsvCache.done = ffmpegFile, ok, true
if qsvCache.path != ffmpegFile || qsvCache.avail == nil {
qsvCache.path, qsvCache.avail = ffmpegFile, map[string]bool{}
}
qsvCache.avail[encoder] = ok
qsvCache.Unlock()
return ok
}