From a891094f294c8d174fe02fd85e32d7a0f4022b10 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Fri, 25 Sep 2026 19:23:44 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=88=E5=B9=B6=E9=BB=98=E8=AE=A4=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20HEVC=E3=80=81=E7=A0=81=E7=8E=87=E6=8C=89=E3=80=8C?= =?UTF-8?q?=E6=BA=90=E7=89=87=20+=20500=E3=80=8D=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E7=AE=97=EF=BC=9B=E6=89=8B=E5=8A=A8=E5=90=88=E5=B9=B6=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8C=B9=E9=85=8D=E5=90=8C=E5=90=8D=E5=BC=B9=E5=B9=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两件事: 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 干净。 --- README.md | 8 ++- danmuburn.go | 165 ++++++++++++++++++++++++++++++++++---------- doc/cli.md | 2 +- doc/webapi.md | 6 +- encode_test.go | 104 ++++++++++++++++++++++++++++ layout_test.go | 30 +------- main.go | 2 +- manualmerge.go | 46 ++++++++++++ manualmerge_test.go | 116 +++++++++++++++++++++++++++++++ webui.go | 32 ++++++++- 10 files changed, 435 insertions(+), 76 deletions(-) create mode 100644 encode_test.go diff --git a/README.md b/README.md index 55d84d8..7458a98 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe "mergeDelVideo": false, // 合并完成后删除源视频 "mergeDelDanmu": false, // 合并完成后删除弹幕文件(ass、nick.ass、jsonl) "mergeOutputDir": "", // 合并完成后成片移动到该文件夹,留空则不移动 - "mergeBitrate": 0, // 合并成片的目标码率(Kbps),0 表示使用默认质量 + "mergeBitrate": 0, // 合并成片的目标码率(Kbps),0 表示按「源片码率 + 500」自动计算 "danmuFontSize": 0, // 合并时弹幕文字的字号(像素),0 表示跟随弹幕文件;头像随字号等比缩放 "defaultBitrate": 0, // 新添加主播的默认 bitrate,0 表示码率最高的直播源 @@ -208,7 +208,9 @@ acfunlive -danmuburn xxx.jsonl -video xxx.flv -ffmpeg D:\tools\ffmpeg.exe ### 自动合并 -开启`autoMerge`后,主播下播且弹幕和录播都自然结束时,会自动把该场录播和弹幕合成为`xxx_弹幕合并.mp4`,之后按设置删除源视频和弹幕文件,并把成片移动到`mergeOutputDir`。`mergeBitrate`大于 0 时成片使用指定目标码率,可以明显减小成片体积。 +开启`autoMerge`后,主播下播且弹幕和录播都自然结束时,会自动把该场录播和弹幕合成为`xxx_弹幕合并.mp4`,之后按设置删除源视频和弹幕文件,并把成片移动到`mergeOutputDir`。 + +成片码率默认(`mergeBitrate`填 0)按**源片码率 + 500 Kbps**算 —— 烧进去的文字和头像都是高频细节,和源片同码率会糊,所以多给一点余量;源片码率探不到时退回质量模式(QSV 用 ICQ 22、软编用 CRF 18)。想固定体积就自己填一个值。编码器优先用硬件 **HEVC(hevc_qsv)**,同画质比 H.264 省三成左右体积、耗时几乎一样,成片在 mp4 里打`hvc1`标记以便 Apple 系播放;显卡不支持时依次回退到`h264_qsv`、`libx264`。注意 Windows 自带播放器要装 HEVC 扩展才能放,PotPlayer、VLC、mpv 不用。 直播流中途中断时录制会自动重连并产生新的视频分段,合并会把**所有分段拼成一个完整视频**,弹幕时间轴也按各段时长重新对齐,断流期间的弹幕挪到下一段开头,所以后半场不会丢弹幕。分段时长探测不到时会退化为只合并第一段并给出提示。 @@ -227,7 +229,7 @@ acfunlive -danmuburn xxx.jsonl -video xxx.flv -ffmpeg D:\tools\ffmpeg.exe web UI 的`合并`分区里的`手动合并`用来自己挑视频和弹幕出片,适合手动停止录制过的一场、或者手里只有零散文件的情况。 - **视频**:既可以在文本框里一行一个地填,也可以从录播目录的下拉列表里多选(选完自动填进文本框)。填的名字不带路径就按录播目录找,带路径的相对和绝对都能用,所以录播目录以外的文件也合得来。粘贴路径时带上的引号会被自动去掉。 -- **弹幕**:支持`.jsonl`和`.ass`。填`.ass`时会先还原成 jsonl 再合并(规则和`-danmuburn`一样,优先用同名的 jsonl),还原结果写回录播目录,页面的下拉列表里也会出现。 +- **弹幕**:支持`.jsonl`和`.ass`。填`.ass`时会先还原成 jsonl 再合并(规则和`-danmuburn`一样,优先用同名的 jsonl),还原结果写回录播目录,页面的下拉列表里也会出现。**选好视频会自动找同名弹幕文件**(同目录下依次找`.jsonl`、`.ass`、`.nick.ass`,同目录没有再去录播目录找一次),找到就自动填上并提示一句;找不到、或者弹幕框里已经有内容时就保持原样,自己手选即可。 - **时间偏移**:留空时按文件名里的录制时间自动推算(视频时间 = 弹幕时间 + 偏移),也可以自己填,比如录播比弹幕晚开始 3 秒就填`3`。 - **输出文件名**:可以改,重名时自动加`(2)`之类的后缀,不会覆盖已有文件。 - **先预览再合并**:`预览`按钮会先把分段顺序、各段时长、总时长、弹幕条数和时间范围、推算出的偏移、以及要用的弹幕文件列出来,确认没问题再点`开始合并`。 diff --git a/danmuburn.go b/danmuburn.go index 2ee509d..20eb71d 100644 --- a/danmuburn.go +++ b/danmuburn.go @@ -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 } diff --git a/doc/cli.md b/doc/cli.md index ffd59fb..342004d 100644 --- a/doc/cli.md +++ b/doc/cli.md @@ -11,7 +11,7 @@ `acfunlive -mirai` 利用 Mirai 发送直播通知到指定 QQ 或 QQ 群 -`acfunlive -danmuburn xxx.jsonl -video xxx.flv` 把弹幕合成到视频里,不指定`-video`时只输出带头像的弹幕层。弹幕文件也接受上游原版的`.ass`(会先还原成 jsonl 再合并),可以用`-out`、`-offset`、`-vbitrate`、`-ffmpeg`分别指定输出文件、时间偏移、目标码率和 ffmpeg 的位置 +`acfunlive -danmuburn xxx.jsonl -video xxx.flv` 把弹幕合成到视频里,不指定`-video`时只输出带头像的弹幕层。弹幕文件也接受上游原版的`.ass`(会先还原成 jsonl 再合并),可以用`-out`、`-offset`、`-vbitrate`、`-ffmpeg`分别指定输出文件、时间偏移、目标码率和 ffmpeg 的位置。`-vbitrate`不填(或 0)且配置文件里的`mergeBitrate`也是 0 时,按「源片码率 + 500 Kbps」自动算;编码器优先硬件 HEVC(hevc_qsv),依次回退 h264_qsv、libx264 `acfunlive -listlive` 列出正在直播的主播 diff --git a/doc/webapi.md b/doc/webapi.md index c15c6bb..af48b56 100644 --- a/doc/webapi.md +++ b/doc/webapi.md @@ -80,7 +80,9 @@ web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全 口令支持任意字符,包括中文——不过 HTTP 请求头只接受 ISO-8859-1,浏览器遇到非 ASCII 的口令会直接拒绝发请求(报`String contains non ISO-8859-1 code point`),所以**页面发给服务端的`X-Auth-Token`是百分号编码过的口令**(`encodeURIComponent`)。服务端两种写法都认:先拿原样比,再拿解码后的比,所以手写 curl 或者脚本按原样把中文口令放进请求头、`Authorization: Bearer`、`?token=` 里也都能通过。用脚本调这套接口时不用管编码,原样填就行。 -跟手动合并有关的是下面两个: +跟手动合并有关的是下面三个: +`GET /api/merge/danmu?video=<视频文件名或路径>` 按视频名找同目录下的同名弹幕文件,给页面「选好视频自动匹配弹幕」用。依次试`.jsonl`、`.ass`、`.nick.ass`,同目录没有就拿文件名去录播目录再找一次;返回`{"danmu":"…"}`,找不到时是空字符串。值里录播目录/成片目录下的文件只写文件名,别处的写绝对路径,和`浏览`弹窗的填法一致。 + `POST /api/merge/info` 预览一次手动合并的计划,不落盘。 ``` @@ -99,6 +101,8 @@ web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全 `POST /api/config`里的`danmuFontSize`控制合并时弹幕文字的字号(像素),头像按同一比例缩放,填 0 表示沿用弹幕文件里记的字号。渲染前会按最终字号重排弹道:从上游原版`.ass`还原出来的弹幕只按内容宽度占位,而渲染用的是「昵称(uid):内容 + 头像」的完整宽度,占位时间更长,照搬原弹道会追尾叠字;重排按完整宽度重新分配,同一条弹道上不会再有两条弹幕叠在一起。 +`mergeBitrate`填 0(默认)时按「源片码率 + 500 Kbps」自动计算:先探测视频流的码率,加 500 留给烧进去的弹幕;探测不到就退回质量模式。填大于 0 的值则按该码率固定输出。编码器优先硬件 HEVC(`hevc_qsv`,同画质比 H.264 省约三成体积,mp4 里打`hvc1`标记),不支持时回退`h264_qsv`、`libx264`,日志里会打印实际用的那一个。 + `POST /api/notify/test` 测试某个推送渠道能不能连通。用传进来的值直接测,留空的项自动用已保存的配置补齐,所以不用先保存设置。测试不受渠道开关和事件勾选影响。 ``` diff --git a/encode_test.go b/encode_test.go new file mode 100644 index 0000000..dc58b57 --- /dev/null +++ b/encode_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "strconv" + "strings" + "testing" +) + +// 编码器候选顺序:硬件 HEVC 最省 → 硬件 H.264 → CPU 软编 +func TestEncoderCandidatesOrder(t *testing.T) { + cases := []struct { + hevc, h264 bool + want []string + }{ + {true, true, []string{"hevc_qsv", "h264_qsv", "libx264"}}, + {false, true, []string{"h264_qsv", "libx264"}}, + {true, false, []string{"hevc_qsv", "libx264"}}, + {false, false, []string{"libx264"}}, + } + for _, c := range cases { + got := encoderCandidates(c.hevc, c.h264) + if strings.Join(got, ",") != strings.Join(c.want, ",") { + t.Errorf("hevc=%v h264=%v 应为 %v,实际 %v", c.hevc, c.h264, c.want, got) + } + } + // 候选列表必须能真的跑出结果,最后一个永远是纯 CPU 的 libx264 + last := encoderCandidates(true, true) + if last[len(last)-1] != "libx264" { + t.Errorf("最后一个候选必须是 libx264,实际 %s", last[len(last)-1]) + } +} + +// 编码器参数:固定码率 / 质量模式,HEVC 必须带 hvc1 tag(Apple 系才认) +func TestEncoderOpts(t *testing.T) { + join := func(ss []string) string { return strings.Join(ss, " ") } + + cases := []struct { + encoder string + bitrate int + want []string + }{ + {"hevc_qsv", 4000, []string{"-b:v", "4000k", "-tag:v", "hvc1"}}, + {"h264_qsv", 4000, []string{"-b:v", "4000k"}}, + {"libx264", 4000, []string{"-b:v", "4000k", "-preset", "medium"}}, + {"hevc_qsv", 0, []string{"-global_quality", "22", "-preset", "medium", "-tag:v", "hvc1"}}, + {"libx265", 0, []string{"-crf", "18", "-preset", "medium", "-tag:v", "hvc1"}}, + } + for _, c := range cases { + got := encoderOpts(c.encoder, c.bitrate) + if join(got) != join(c.want) { + t.Errorf("%s @ %d 应为 %v,实际 %v", c.encoder, c.bitrate, c.want, got) + } + } +} + +// 「合成码率」填 0 时的算法:源片码率 + 弹幕余量,探测不到就交回 0(走质量模式) +func TestAutoMergeBitrate(t *testing.T) { + cases := []struct { + source int + want int + }{ + {3197, 3697}, // 用户那场录播的源片码率 + {0, 0}, // 探测不到 → 质量模式 + {-1, 0}, + {100, 800}, // 低于下限时抬到下限 + {3000, 3500}, // 正常情况就是 +500 + } + for _, c := range cases { + if got := autoMergeBitrate(c.source); got != c.want { + t.Errorf("源码率 %d 应得 %d,实际 %d", c.source, c.want, got) + } + } +} + +// ffmpeg 输出的解析:分辨率靠「逗号 + 数字x数字」,码率靠视频流那一行的 kb/s, +// 视频流没写码率时退回容器总码率 +func TestProbeStreamRegex(t *testing.T) { + sizeLine := " Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(tv, bt709, progressive), 1920x1080 [SAR 1:1 DAR 16:9], 3217 kb/s, 23.98 fps" + m := videoSizeRe.FindStringSubmatch(sizeLine) + if len(m) != 3 { + t.Fatalf("没解析出分辨率:%s", sizeLine) + } + w, _ := strconv.Atoi(m[1]) + h, _ := strconv.Atoi(m[2]) + if w != 1920 || h != 1080 { + t.Errorf("分辨率解析成 %dx%d", w, h) + } + // 视频流那一行里的码率(不是编码器名里的 0x…) + if b := videoBitrateRe.FindStringSubmatch(sizeLine); len(b) != 2 || b[1] != "3217" { + t.Errorf("视频流码率没解析对:%v", b) + } + + portrait := " Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(tv, smpte170m/unknown/unknown, progressive), 720x1280 [SAR 1:1 DAR 9:16], 1570 kb/s, 10 fps" + m = videoSizeRe.FindStringSubmatch(portrait) + if len(m) != 3 || m[1] != "720" || m[2] != "1280" { + t.Errorf("竖屏分辨率没解析对:%v", m) + } + + // 视频流那行没写码率时用容器总码率兜底 + durLine := " Duration: 00:02:55.07, start: 0.000000, bitrate: 1767 kb/s" + if m := containerBitrateRe.FindStringSubmatch(durLine); len(m) != 2 || m[1] != "1767" { + t.Errorf("容器码率没解析对:%v", m) + } +} diff --git a/layout_test.go b/layout_test.go index 02939ca..dc4df76 100644 --- a/layout_test.go +++ b/layout_test.go @@ -1,9 +1,6 @@ package main -import ( - "strconv" - "testing" -) +import "testing" // 还原自 .ass 的弹幕:原格式的弹道是按「只有内容」的宽度占位的,渲染用的是 // 「昵称(uid):内容 + 头像」的宽度,占位更长 → 同弹道会追尾。重排后必须分开。 @@ -167,31 +164,6 @@ func TestUnifyDanmuSpace(t *testing.T) { } } -// ffmpeg 输出的解析:分辨率要靠「逗号 + 数字x数字」,别被编码器名里的 0x… 骗到 -func TestProbeVideoSizeRegex(t *testing.T) { - cases := []struct { - line string - w, h int - }{ - {" Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(tv, bt709, progressive), 1920x1080 [SAR 1:1 DAR 16:9], 1500 kb/s, 23.98 fps", 1920, 1080}, - {" Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(tv, smpte170m/unknown/unknown, progressive), 720x1280 [SAR 1:1 DAR 9:16], 1570 kb/s, 10 fps", 720, 1280}, - {" Stream #0:0: Video: hevc (Main) (hvc1 / 0x31637668), yuv420p(tv), 3840x2160, 8000 kb/s", 3840, 2160}, - {" Stream #0:0(und): Video: h264 (High), yuv420p, 640x480, 30 fps", 640, 480}, - } - for _, c := range cases { - m := videoSizeRe.FindStringSubmatch(c.line) - if len(m) != 3 { - t.Errorf("没解析出行:%s", c.line) - continue - } - w, _ := strconv.Atoi(m[1]) - h, _ := strconv.Atoi(m[2]) - if w != c.w || h != c.h { - t.Errorf("解析成 %dx%d,应为 %dx%d:%s", w, h, c.w, c.h, c.line) - } - } -} - // 按渲染时的公式统计同弹道追尾条数 func countLaneOverlaps(records []danmuRecord, meta danmuMeta) int { show := int64(danmuShowSeconds * 1e9) diff --git a/main.go b/main.go index ba99a58..e74e7c2 100644 --- a/main.go +++ b/main.go @@ -29,7 +29,7 @@ func argsHandle() { burnOut := flag.String("out", "", "合成输出文件路径,默认与弹幕文件同目录") burnOffset := flag.Float64("offset", 0, "录播视频相对弹幕录制开始的时间偏移,单位秒,配合 -danmuburn -video 使用") burnFFmpeg := flag.String("ffmpeg", "", "ffmpeg 可执行文件路径;不指定时读设置文件里的 ffmpegPath,仍旧找不到再按常见位置查找") - burnBitrate = *flag.Int("vbitrate", 0, "合成视频的目标码率(Kbps),0 为默认质量,配合 -danmuburn 使用") + burnBitrate = *flag.Int("vbitrate", 0, "合成视频的目标码率(Kbps),0 为按「源片码率 + 500」自动计算,配合 -danmuburn 使用") flag.Parse() if *isHelp { diff --git a/manualmerge.go b/manualmerge.go index c9d9f01..54cb9b6 100644 --- a/manualmerge.go +++ b/manualmerge.go @@ -119,6 +119,52 @@ func resolveMergeInput(value string) (string, error) { return "", fmt.Errorf("找不到文件:%s", value) } +// 找同名弹幕文件时按这个顺序试:本程序录的 .jsonl 带头像链接,优先用 +var mergeDanmuCandidates = []string{".jsonl", ".ass", ".nick.ass"} + +// 按视频文件找同目录下的同名弹幕文件,找不到返回空字符串。 +// 同目录找不到时再拿文件名去录播目录里试一次(视频被挪走、弹幕还留在录播目录的情况) +func findDanmuForVideo(video string) string { + full, err := resolveMergeInput(video) + if err != nil { + return "" + } + base := strings.TrimSuffix(full, filepath.Ext(full)) + dirs := []string{filepath.Dir(full)} + if recDir := recordSaveDir(); normalizePath(recDir) != normalizePath(filepath.Dir(full)) { + dirs = append(dirs, recDir) + } + for _, dir := range dirs { + for _, ext := range mergeDanmuCandidates { + cand := filepath.Join(dir, filepath.Base(base)+ext) + if info, err := os.Stat(cand); err == nil && !info.IsDir() { + return cand + } + } + } + return "" +} + +// 填回输入框时的写法:录播目录/成片目录里的只写文件名,别处的写绝对路径(和浏览弹窗一致) +func mergeFillValue(path string) string { + dir := normalizePath(filepath.Dir(path)) + if dir == normalizePath(recordSaveDir()) || dir == normalizePath(mergeSaveDir()) { + return filepath.Base(path) + } + return path +} + +// GET /api/merge/danmu?video=xxx —— 页面选好视频后自动找同名弹幕文件用 +func mergeDanmuMatchHandler(w http.ResponseWriter, r *http.Request) { + path := findDanmuForVideo(r.URL.Query().Get("video")) + resp := map[string]string{"danmu": ""} + if path != "" { + resp["danmu"] = mergeFillValue(path) + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(resp) +} + // 扩展名白名单的说明文字 func extListText(m map[string]struct{}) string { out := make([]string, 0, len(m)) diff --git a/manualmerge_test.go b/manualmerge_test.go index ca1919d..137c52c 100644 --- a/manualmerge_test.go +++ b/manualmerge_test.go @@ -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) + } + }) +} diff --git a/webui.go b/webui.go index b05ff11..97b1030 100644 --- a/webui.go +++ b/webui.go @@ -307,7 +307,7 @@ const webuiHTML = ` -
+
文本框改完按回车或点右上角「保存设置」生效,不做这一步的话 3 秒后会被刷回原值。
@@ -325,7 +325,7 @@ const webuiHTML = `
- +
@@ -778,6 +778,8 @@ async function saveSettings(b, autoSave){ ["recordFormat","meowMsgType"].forEach(function(id){ document.getElementById(id).addEventListener("change", function(){ dropDirty(id); saveSettings(null, true); }); }); +// 手动合并的视频框:手填/粘贴完(失焦)也自动找一次同名弹幕文件 +document.getElementById("mrgVideos").addEventListener("change", function(){ autoMatchDanmu(); }); // HTML 高度只对 html 类型有意义,选 text 时收起来 function toggleMeowHeight(){ var isHTML = document.getElementById("meowMsgType").value === "html"; @@ -984,6 +986,22 @@ function paintPickSel(){ updatePickBar(); } function pickClearSel(){ pickSel = []; paintPickSel(); } +// 选好视频后自动找同目录同名的弹幕文件(.jsonl / .ass),找不到就让用户自己选。 +// 弹幕框里已经有内容时不动它,避免把用户填好的覆盖掉 +async function autoMatchDanmu(){ + var vEl = document.getElementById("mrgVideos"), dEl = document.getElementById("mrgDanmu"); + if(!vEl || !dEl || dEl.value.trim()) return; + var lines = vEl.value.split("\n").map(function(s){ return s.trim(); }).filter(function(s){ return s; }); + if(!lines.length) return; + var r = null; + try{ r = await api("/api/merge/danmu?video=" + encodeURIComponent(lines[0])); }catch(e){} + if(!r || !r.ok) return; + var d = null; + try{ d = await r.json(); }catch(e){ return; } + if(!d || !d.danmu) return; + dEl.value = d.danmu; + showMsg(true, "已按视频文件名自动匹配弹幕:" + d.danmu); +} // 录播目录下的文件就只填文件名,框里清爽,保存目录以后改了也还认得;别处的填绝对路径 function pickFillValue(p){ if(!recDir) return p; @@ -1009,6 +1027,7 @@ function pickConfirm(){ if(settingFields.indexOf(pickField) >= 0) markDirty(pickField); } closePick(); + if(pickField === "mrgVideos") autoMatchDanmu(); return; } var v = pickKind === "file" ? pickFile : pickCur; @@ -1098,7 +1117,10 @@ function pickVideos(){ var names = []; Array.prototype.forEach.call(document.getElementById("mrgVideoPick").options, function(o){ if(o.selected) names.push(o.value); }); names.sort(); // 按文件名里的录制时间排,点选顺序无所谓 - if(names.length) document.getElementById("mrgVideos").value = names.join("\n"); + if(names.length){ + document.getElementById("mrgVideos").value = names.join("\n"); + autoMatchDanmu(); + } } function setMergeState(){ var go = document.getElementById("mrgGo"); @@ -1108,6 +1130,8 @@ function setMergeState(){ document.getElementById("mrgState").textContent = mrgBusy ? "正在合并,进度见「监控」页的运行日志" : ""; } async function checkMerge(){ + // 弹幕框还空着就先按视频文件名自动找一次(选了视频直接点检查也能用) + if(!document.getElementById("mrgDanmu").value.trim()) await autoMatchDanmu(); var body = mrgPayload(); if(!body) return; if(!body.videos.length || !body.danmu){ showMsg(false, "请先填写视频和弹幕文件"); return; } @@ -1131,6 +1155,7 @@ async function checkMerge(){ document.getElementById("mrgInfo").textContent = lines.join("\n"); } async function startMerge(){ + if(!document.getElementById("mrgDanmu").value.trim()) await autoMatchDanmu(); var body = mrgPayload(); if(!body) return; if(!body.videos.length || !body.danmu){ showMsg(false, "请先填写视频和弹幕文件"); return; } @@ -1841,6 +1866,7 @@ func startWebUI(ctx context.Context) { mux.HandleFunc("GET /api/file", fileDownloadHandler) mux.HandleFunc("POST /api/file/remove", fileRemoveHandler) mux.HandleFunc("POST /api/merge/info", mergeInfoHandler) + mux.HandleFunc("GET /api/merge/danmu", mergeDanmuMatchHandler) mux.HandleFunc("POST /api/merge", mergeHandler) srvCtx, srvCancel := context.WithCancel(ctx)