合并任务的可视化进度、预计剩余时间与暂停/停止

用户反馈:合并的进度只体现在日志里(ffmpeg 的 frame=/speed= 行),看不出
百分比、当前环节和还要多久,也没有办法中途暂停或放弃。

1. 新增全局任务状态(progress.go)
   一份状态同时服务自动合并和手动合并,记录阶段、总进度、速度、帧率、
   已处理时长、输入/预计输出/已写出体积、ETA 和速度折线采样点。
   总进度按阶段加权:准备 0.02 / 分段拼接 0.08 / 获取头像 0.10 /
   画面渲染 0.75 / 拼接与落盘 0.05,渲染阶段按「已渲染 ÷ 总时长」折算。
   ETA 在渲染阶段用「剩余时长 ÷ 倍速」,其它阶段按已用时间线性外推。

2. 用 ffmpeg 的 -progress pipe:1 取结构化进度
   runFFmpeg 拆出带回调的版本:解析 out_time_us / speed / fps / total_size,
   分块渲染时把块内时间折算到整片时间轴,并把已完成块的字节数累计进来。
   ffmpeg 自己的统计行仍在 stderr,单独用命令行时不会少东西。
   预计成片体积先按「(视频码率 + 音频 192k)× 时长 ÷ 8」估,渲染过 5%
   之后换成「已写出字节 ÷ 已完成比例」外推(estimateAudioKbps)。

3. 暂停 / 停止
   Windows 用 ntdll 的 NtSuspendProcess / NtResumeProcess(照任务管理器
   的「挂起」语义),Linux 用 SIGSTOP / SIGCONT,放在带 build tag 的
   jobctl_windows.go / jobctl_unix.go 里。
   非 ffmpeg 环节(下载头像、逐块渲染之间)用 jobGate 闸门生效。
   停止 = 杀掉当前 ffmpeg 并置位,各层返回 errJobStopped:不再回退其它
   编码器、不落成片、清掉分块临时文件,任务以「已停止」结束(而不是失败)。

4. 接口
   GET /api/job 给页面轮询任务快照;POST /api/job/control 接受
   pause / resume / stop,没有任务时 409、未知动作 400。

5. 界面
   监控分区顶部新增「转换任务」卡片:阶段步骤条、总进度条、实时指标、
   预计剩余时间、源片与预计成片的体积对比条、渲染速度折线(每秒一个点、
   最多两分钟),以及暂停/继续/停止按钮;页面最顶上再加一条全局细进度条,
   切到哪个分区都看得到。有任务时每秒轮询,空闲时 4 秒。任务结束后卡片
   再停留 20 秒显示结果。

已验证:
- 新增 progress_test.go 12 个用例:阶段权重和为 1、进度按阶段折算、阶段
  不回退、ETA、体积预估与实测外推的切换、折线采样、暂停/停止的闸门语义、
  控制接口在无任务时报错、speed 字段与 -progress 文本的解析。
  测试里把 errJobStopped 和真失败区分开(jobErrText)。
- 端到端(独立实例 51897 + 独立 config,全程没碰仓库里的实例和
  acfunlive.exe):跑通完整合并,进度到 100%、预计体积与成片实际大小
  相差 0.4%;暂停后进度冻结且 ffmpeg 的 CPU 时间 5 秒内 0 增长;
  继续后进度恢复;停止后任务以「已停止」结束、没有半成品成片、
  没有残留临时文件、没有残留 ffmpeg 进程。
- 界面(playwright 驱动系统 Edge):步骤条 5 个阶段且只有一个处于进行中、
  进度条与顶部细条同步、5 项指标(速度/帧率/已处理时长/已用时间/已写出)、
  ETA 文案、体积对比条两段宽度按比例、折线有采样点、界面按钮能暂停/继续/
  停止、结束后按钮隐藏且文案是「已停止」、深色主题与 390 宽移动端无横向溢出。
- 过程中修掉三个真缺陷:停止时残留分块临时文件(part 登记晚于渲染)、
  界面「继续」按钮点不动(onclick 写死了 pause)、停止被当成失败报红
  (现在按 message 区分 已完成/已停止/失败)。
- gofmt / go vet / go build / GOOS=linux go build / go test 全过。
This commit is contained in:
tamakyi
2026-09-26 02:05:47 +08:00
parent cbe5dfa982
commit 7dd2487cb8
11 changed files with 1472 additions and 49 deletions
+251
View File
@@ -204,9 +204,44 @@ const webuiHTML = `<!DOCTYPE html>
button.ghost,.mask button{padding:8px 12px;font-size:13px}
#toast{bottom:14px}
}
/* 转换任务进度:监控分区里的卡片 + 页面顶部的全局细进度条 */
#jobTop{position:fixed;left:0;right:0;top:0;height:3px;z-index:12;pointer-events:none;display:none}
#jobTop.on{display:block}
#jobTop i{display:block;height:100%;width:0;background:var(--accent);transition:width .6s linear}
.jtop{display:flex;align-items:baseline;gap:8px;font-size:12px;color:var(--sub);flex-wrap:wrap}
.jtop b{font-size:15px;color:var(--text);font-variant-numeric:tabular-nums}
.jbar{height:8px;border-radius:999px;background:var(--soft);overflow:hidden;margin:8px 0 2px}
.jbar i{display:block;height:100%;width:0;border-radius:999px;background:var(--accent);transition:width .6s linear}
.jbar.paused i{background:var(--sub)}
/* 阶段步骤条 */
.steps{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px}
.step{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;color:var(--sub);background:var(--soft2);border-radius:999px;padding:3px 10px}
.step .d{width:6px;height:6px;border-radius:50%;background:var(--line);flex:none}
.step.done .d{background:var(--ok)}
.step.on{color:var(--accent);background:var(--accent-soft);font-weight:600}
.step.on .d{background:var(--accent)}
.step.paused{color:var(--bad)}
.step.paused .d{background:var(--bad)}
/* 指标网格 */
.jgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(108px,1fr));gap:8px 14px;margin-top:10px}
.jcell{min-width:0}
.jcell .k{font-size:11px;color:var(--sub);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.jcell .v{font-size:13px;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* 输入 / 预计输出体积对比条,两段按较大值等比缩放 */
.vbar{display:flex;height:8px;border-radius:999px;overflow:hidden;background:var(--soft);margin-top:5px}
.vbar span{height:100%}
.vbar .vin{background:var(--sub)}
.vbar .vout{background:var(--accent)}
.vlg{display:flex;gap:14px;font-size:11.5px;color:var(--sub);margin-top:4px}
.vlg i{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:5px}
/* 速度折线:颜色走 CSS 变量,属性里写 var() 在部分浏览器不生效 */
.spark{display:block;width:100%;height:48px;margin-top:10px;border-radius:var(--radius-s);background:var(--soft2)}
.spark polyline{fill:none;stroke:var(--accent);stroke-width:1.6;stroke-linejoin:round;stroke-linecap:round;vector-effect:non-scaling-stroke}
.spark text{font-size:9px;fill:var(--sub)}
</style>
</head>
<body>
<div id="jobTop"><i id="jobTopBar"></i></div>
<header class="top">
<div class="top-in">
<div class="brand">
@@ -232,6 +267,35 @@ const webuiHTML = `<!DOCTYPE html>
<!-- 监控 -->
<section class="pane on" data-pane="mon">
<div class="card" id="jobCard" style="display:none">
<div class="card-h">
<h2 id="jobTitle">转换任务</h2>
<span class="sub" id="jobOut"></span>
<span style="flex:1 1 auto"></span>
<button class="ghost" id="jobPause" data-act="pause" onclick="jobControl(this.dataset.act)">暂停</button>
<button class="ghost" id="jobStop" onclick="jobControl('stop')">停止</button>
</div>
<div class="steps" id="jobSteps"></div>
<div class="jtop">
<b id="jobPct">0%</b>
<span id="jobPhase"></span>
<span style="flex:1 1 auto"></span>
<span id="jobEta"></span>
</div>
<div class="jbar" id="jobBarWrap"><i id="jobBar"></i></div>
<div class="jgrid" id="jobNums"></div>
<div id="jobVol" style="display:none">
<div class="vbar"><span class="vin" id="jobVolIn"></span><span class="vout" id="jobVolOut"></span></div>
<div class="vlg">
<span><i style="background:var(--sub)"></i>源片 <b id="jobVolInText">—</b></span>
<span><i style="background:var(--accent)"></i>预计成片 <b id="jobVolOutText">—</b></span>
<span id="jobVolNote"></span>
</div>
</div>
<div id="jobSparkWrap" style="display:none">
<svg class="spark" id="jobSpark" viewBox="0 0 300 48" preserveAspectRatio="none"></svg>
</div>
</div>
<div class="card">
<div class="card-h"><h2>添加主播</h2><span class="sub">填 uid 后默认同时开启弹幕和录播</span></div>
<div class="rowline">
@@ -1424,6 +1488,191 @@ showTab(location.hash.slice(1) || localStorage.getItem("acfunlive_tab") || "mon"
authed = true;
refresh();
setInterval(refresh, 3000);
// ===== 转换任务进度(进度、阶段、预计剩余、体积;暂停 / 停止)=====
// 结束之后还留一会儿,让用户看得到「已完成 / 已停止」的结果
var JOB_LINGER_MS = 20000;
// 自动任务结束后要播报一下,同一次任务只报一次
var jobAnnounced = "";
function fmtBytes(n){
if(!n || n <= 0) return "—";
var u = ["B","KB","MB","GB","TB"], i = 0;
while(n >= 1024 && i < u.length - 1){ n /= 1024; i++; }
return (i === 0 ? n : n.toFixed(n >= 100 ? 0 : 1)) + " " + u[i];
}
function fmtDur(sec){
if(!sec || sec <= 0 || !isFinite(sec)) return "—";
sec = Math.round(sec);
var h = Math.floor(sec/3600), m = Math.floor(sec%3600/60), s = sec%60;
var p = function(x){ return x < 10 ? "0" + x : "" + x; };
return (h ? h + ":" : "") + p(m) + ":" + p(s);
}
function fmtEta(sec){
if(!sec || sec <= 0 || !isFinite(sec)) return "剩余时间估算中…";
sec = Math.round(sec);
if(sec < 60) return "剩余约 " + sec + " 秒";
var m = Math.floor(sec/60), s = sec%60;
if(m < 60) return "剩余约 " + m + " 分" + (s ? " " + s + " 秒" : "");
var h = Math.floor(m/60);
return "剩余约 " + h + " 小时 " + (m%60) + " 分";
}
function renderJob(j){
var card = document.getElementById("jobCard");
var topEl = document.getElementById("jobTop");
var recent = j.doneAt > 0 && (Date.now() - j.doneAt*1000) < JOB_LINGER_MS;
if(!j.active && !recent){
card.style.display = "none";
topEl.classList.remove("on");
return;
}
card.style.display = "";
var phases = j.phases || [];
var pct = Math.max(0, Math.min(100, j.percent || 0));
// 「被手动停止」不是失败,别当成错误报出来
var failed = !!j.err && j.err !== "任务已被手动停止";
document.getElementById("jobOut").textContent =
(j.kind || "") + (j.outName ? " · " + j.outName : "");
// 控制按钮只在跑的时候出现;暂停中也允许直接停止(后台杀挂起的进程没问题)
var pb = document.getElementById("jobPause"), sb = document.getElementById("jobStop");
if(j.active){
pb.style.display = ""; sb.style.display = "";
pb.textContent = j.paused ? "继续" : "暂停";
pb.dataset.act = j.paused ? "resume" : "pause";
pb.disabled = !!j.stopping;
sb.disabled = !!j.stopping;
sb.title = "中断这次合并,已渲染的部分会被丢弃";
}else{
pb.style.display = "none";
sb.style.display = "none";
}
// 阶段步骤条:走过的打勾,当前的按是否暂停上色
document.getElementById("jobSteps").innerHTML = phases.map(function(name, i){
var cls = "step";
if(j.active){
if(i < j.phaseIdx) cls += " done";
else if(i === j.phaseIdx) cls += (j.paused ? " paused" : " on");
}else if(!failed || i < j.phaseIdx){
cls += " done";
}
return '<span class="' + cls + '"><i class="d"></i>' + esc(name) + '</span>';
}).join("");
document.getElementById("jobPct").textContent = pct.toFixed(pct >= 10 ? 0 : 1) + "%";
var phaseText = "";
if(!j.active){
// message 由服务端给(已完成 / 已停止 / 失败),只有真失败才补上原因
phaseText = j.message || "已完成";
if(j.err && j.message === "失败") phaseText += ":" + j.err;
}else if(j.paused){
phaseText = "已暂停(ffmpeg 已挂起,CPU 不再占用)";
}else if(j.stopping){
phaseText = "正在停止…";
}else if(j.phaseIdx >= 0 && j.phaseIdx < phases.length){
phaseText = "正在" + phases[j.phaseIdx];
if(j.phaseIdx === 2 && j.avatarTotal > 0)
phaseText += "(" + j.avatarDone + "/" + j.avatarTotal + " 个用户)";
if(j.phaseIdx === 3 && j.chunkTotal > 1)
phaseText += "(第 " + Math.min(j.chunkDone + 1, j.chunkTotal) + "/" + j.chunkTotal + " 块)";
}
document.getElementById("jobPhase").textContent = phaseText;
document.getElementById("jobEta").textContent =
(j.active && !j.paused) ? fmtEta(j.etaSec) : "";
var bar = document.getElementById("jobBarWrap");
document.getElementById("jobBar").style.width = pct + "%";
bar.className = "jbar" + (j.paused ? " paused" : "");
var cells = [];
var cell = function(k, v){ cells.push('<div class="jcell"><div class="k">' + k + '</div><div class="v">' + v + '</div></div>'); };
cell("渲染速度", j.speed > 0 ? j.speed.toFixed(2) + "x" : "—");
cell("帧率", j.fps > 0 ? j.fps.toFixed(1) + " fps" : "—");
cell("已处理时长", fmtDur(j.doneSec) + " / " + fmtDur(j.totalSec));
cell("已用时间", fmtDur(j.elapsedSec));
cell("已写出", fmtBytes(j.outBytes));
document.getElementById("jobNums").innerHTML = cells.join("");
// 源片 / 预计成片体积对比:两段按较大值等比缩放
var inB = j.inputBytes || 0, outB = j.estOutBytes || 0;
var vol = document.getElementById("jobVol");
if(inB > 0 || outB > 0){
vol.style.display = "";
var maxB = Math.max(inB, outB, 1);
document.getElementById("jobVolIn").style.width = (inB/maxB*100) + "%";
document.getElementById("jobVolOut").style.width = (outB/maxB*100) + "%";
document.getElementById("jobVolInText").textContent = fmtBytes(inB);
document.getElementById("jobVolOutText").textContent = fmtBytes(outB);
document.getElementById("jobVolNote").textContent = outB <= 0 ? ""
: (j.estSource === "measured" ? "(按已渲染部分推算)" : "(按目标码率估算)");
}else{
vol.style.display = "none";
}
// 渲染速度折线:横轴是任务已用时间,纵轴是倍速
var samples = j.samples || [];
var wrap = document.getElementById("jobSparkWrap");
if(samples.length >= 2){
wrap.style.display = "";
var W = 300, H = 48, PAD = 5, maxS = 0;
samples.forEach(function(s){ if(s.speed > maxS) maxS = s.speed; });
if(maxS <= 0) maxS = 1;
var t0 = samples[0].t, span = Math.max(samples[samples.length-1].t - t0, 1);
var pts = samples.map(function(s){
var x = PAD + (s.t - t0)/span * (W - PAD*2);
var y = H - PAD - (s.speed/maxS) * (H - PAD*2);
return x.toFixed(1) + "," + y.toFixed(1);
}).join(" ");
var last = samples[samples.length-1];
document.getElementById("jobSpark").innerHTML =
'<polyline points="' + pts + '"/>' +
'<text x="' + PAD + '" y="12">峰值 ' + maxS.toFixed(2) + 'x</text>' +
'<text x="' + (W - PAD) + '" y="12" text-anchor="end">当前 ' + (last.speed || 0).toFixed(2) + 'x</text>';
}else{
wrap.style.display = "none";
}
if(j.active){
topEl.classList.add("on");
document.getElementById("jobTopBar").style.width = pct + "%";
}else{
topEl.classList.remove("on");
// 结束后播报一次(轮到哪个分区都看得见)
var key = String(j.doneAt);
if(jobAnnounced !== key){
jobAnnounced = key;
if(failed) showMsg(false, "转换任务失败:" + j.err);
else showMsg(true, "转换任务" + (j.message || "已完成"));
}
}
}
async function jobControl(act){
if(act === "stop" && !confirm("确定停止当前的合并任务吗?已经渲染的部分会被丢弃,不会生成成片。")) return;
var r = null;
try{
r = await api("/api/job/control", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({action: act})});
}catch(e){}
if(!r || !r.ok){ showMsg(false, r ? await r.text() : "操作失败"); return; }
showMsg(true, act === "pause" ? "已暂停" : (act === "resume" ? "已继续" : "正在停止…"));
pollJob();
}
// 有任务在跑就 1 秒刷一次,空闲时 4 秒
async function pollJob(){
var j = null;
if(authed){
try{
var r = await api("/api/job");
if(r.status === 401) showLogin("管理口令已失效,请重新输入");
else if(r.ok) j = await r.json();
}catch(e){}
if(j) renderJob(j);
}
setTimeout(pollJob, (j && j.active) ? 1000 : 4000);
}
pollJob();
document.getElementById("tokenInput").addEventListener("keydown", function(e){ if(e.key === "Enter") login(); });
</script>
</body>
@@ -2117,6 +2366,8 @@ func startWebUI(ctx context.Context) {
mux.HandleFunc("POST /api/merge/info", mergeInfoHandler)
mux.HandleFunc("GET /api/merge/danmu", mergeDanmuMatchHandler)
mux.HandleFunc("POST /api/merge", mergeHandler)
mux.HandleFunc("GET /api/job", jobHandler)
mux.HandleFunc("POST /api/job/control", jobControlHandler)
srvCtx, srvCancel := context.WithCancel(ctx)
webSrvMu.Lock()