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