合并默认改用 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:
+127
-38
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user