合并默认改用 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
+29 -3
View File
@@ -307,7 +307,7 @@ const webuiHTML = `<!DOCTYPE html>
<button class="ghost" onclick="openPick('mergeOutputDir','dir')">浏览…</button>
</div>
</div>
<div class="f"><label>合成码率(Kbps,0 = 默认质量)</label><input type="text" id="mergeBitrate" inputmode="numeric" placeholder="例如 1500"></div>
<div class="f"><label>合成码率(Kbps,0 = 源片码率 + 500)</label><input type="text" id="mergeBitrate" inputmode="numeric" placeholder="例如 1500,填 0 自动"></div>
<div class="f"><label>弹幕字号(像素,0 = 跟随弹幕文件)</label><input type="text" id="danmuFontSize" inputmode="numeric" placeholder="例如 42,头像随字号一起缩放"></div>
</div>
<div class="hint">文本框改完按回车或点右上角「保存设置」生效,不做这一步的话 3 秒后会被刷回原值。</div>
@@ -325,7 +325,7 @@ const webuiHTML = `<!DOCTYPE html>
</div>
</div>
<div class="f">
<label>弹幕文件(.jsonl 直接用,.ass 会先还原成 .jsonl)</label>
<label>弹幕文件(.jsonl 直接用,.ass 会先还原成 .jsonl;选了视频会自动找同名的)</label>
<div class="pathf">
<input type="text" id="mrgDanmu" list="mrgDanmuList" autocomplete="off" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.jsonl">
<button class="ghost" onclick="openPick('mrgDanmu','file')">浏览…</button>
@@ -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)