合并任务的可视化进度、预计剩余时间与暂停/停止
用户反馈:合并的进度只体现在日志里(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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user