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

用户反馈:合并的进度只体现在日志里(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
+308
View File
@@ -0,0 +1,308 @@
package main
import (
"errors"
"strings"
"testing"
"time"
)
// 阶段权重加起来必须正好是 1,否则总进度永远到不了 100%
func TestJobPhaseWeightsSumToOne(t *testing.T) {
if len(jobPhaseNames) != len(jobPhaseWeights) {
t.Fatalf("阶段名有 %d 个,权重有 %d 个,对不上", len(jobPhaseNames), len(jobPhaseWeights))
}
sum := 0.0
for _, w := range jobPhaseWeights {
sum += w
}
if sum < 0.999999 || sum > 1.000001 {
t.Errorf("阶段权重之和应为 1,实际 %v", sum)
}
}
// 总进度:走过的阶段按权重满算,当前阶段按比例折算
func TestJobPercentByPhase(t *testing.T) {
jobStart("测试", "x.mp4", 0, 100)
t.Cleanup(func() { jobFinish("") })
if got := jobSnapshot().Percent; got != 0 {
t.Errorf("刚开始时总进度应为 0,实际 %v", got)
}
// 走到渲染阶段但一帧没渲染:前面三个阶段(准备 / 分段拼接 / 头像)已满算
jobSetPhase(jobPhaseRender)
want := (jobPhaseWeights[0] + jobPhaseWeights[1] + jobPhaseWeights[2]) * 100
if got := jobSnapshot().Percent; got < want-0.001 || got > want+0.001 {
t.Errorf("进入渲染阶段时总进度应为 %v,实际 %v", want, got)
}
// 渲染一半
jobRender(50, 1.5, 25, 0)
want += jobPhaseWeights[jobPhaseRender] * 50
if got := jobSnapshot().Percent; got < want-0.001 || got > want+0.001 {
t.Errorf("渲染一半时总进度应为 %v,实际 %v", want, got)
}
// 结束(成功)后必须正好 100%
jobFinish("")
if got := jobSnapshot().Percent; got != 100 {
t.Errorf("完成后总进度应为 100,实际 %v", got)
}
}
// 阶段序号只往前走,重复设置同一阶段不会把进度打回 0
func TestJobPhaseOnlyMovesForward(t *testing.T) {
jobStart("测试", "x.mp4", 0, 100)
defer jobFinish("")
jobSetPhase(jobPhaseRender)
jobRender(60, 1, 25, 0)
before := jobSnapshot().Percent
jobSetPhase(jobPhaseAvatar) // 往回设,应该被忽略
if got := jobSnapshot().Percent; got < before {
t.Errorf("阶段倒退把进度打回去了:%v -> %v", before, got)
}
if idx := jobSnapshot().PhaseIdx; idx != jobPhaseRender {
t.Errorf("阶段应为 %d,实际 %d", jobPhaseRender, idx)
}
}
// 渲染阶段的 ETA = 剩余时长 ÷ 倍速
func TestJobEtaFromSpeed(t *testing.T) {
jobStart("测试", "x.mp4", 0, 200)
defer jobFinish("")
jobSetPhase(jobPhaseRender)
jobRender(50, 2.0, 25, 0)
// 还剩 150 秒,2 倍速 → 75 秒
if got := jobSnapshot().ETASec; got < 74.5 || got > 75.5 {
t.Errorf("ETA 应为约 75 秒,实际 %v", got)
}
}
// 预计输出大小:先给按码率估的值,渲染过 5% 之后换成按已写出字节外推
func TestJobEstOutSize(t *testing.T) {
jobStart("测试", "x.mp4", 0, 100)
defer jobFinish("")
jobSetPhase(jobPhaseRender)
jobPlanSize(5000)
info := jobSnapshot()
if info.EstOutBytes != 5000 || info.EstSource != "bitrate" {
t.Fatalf("还没开始渲染时应按码率估 5000,实际 %d(%s)", info.EstOutBytes, info.EstSource)
}
// 渲染到 50%,写出 4000 字节 → 外推整片 8000
jobRender(50, 1, 25, 4000)
info = jobSnapshot()
if info.EstSource != "measured" {
t.Fatalf("渲染过 5%% 后应改用实测外推,实际来源 %s", info.EstSource)
}
if info.EstOutBytes != 8000 {
t.Errorf("外推应为 8000,实际 %d", info.EstOutBytes)
}
// 比例太小的时候不急着外推,免得开头抖动
jobStart("测试", "x.mp4", 0, 100)
jobSetPhase(jobPhaseRender)
jobPlanSize(5000)
jobRender(1, 1, 25, 100)
if got := jobSnapshot(); got.EstSource != "bitrate" {
t.Errorf("渲染不到 5%% 时不该改用外推,实际来源 %s", got.EstSource)
}
}
// 速度折线:最多保留固定点数,且按秒采样
func TestJobSamples(t *testing.T) {
jobStart("测试", "x.mp4", 0, 100)
defer jobFinish("")
jobSetPhase(jobPhaseRender)
// 同一秒内连着报多次只留一个点
jobRender(1, 1.0, 25, 0)
jobRender(2, 1.1, 25, 0)
jobRender(3, 1.2, 25, 0)
if n := len(jobSnapshot().Samples); n != 1 {
t.Errorf("同一秒内的多次上报应只留 1 个采样点,实际 %d", n)
}
// 攒够 1 秒之后再报就会多一个点
job.Lock()
job.lastSample = time.Now().Add(-2 * time.Second)
job.Unlock()
jobRender(4, 1.3, 25, 0)
samples := jobSnapshot().Samples
if len(samples) != 2 {
t.Fatalf("应有两个采样点,实际 %d", len(samples))
}
if samples[1].Speed != 1.3 {
t.Errorf("第二个采样点的速度应为 1.3,实际 %v", samples[1].Speed)
}
}
// 暂停 / 恢复 / 停止的状态流转;停止后闸门必须让上层拿到 errJobStopped
func TestJobPauseStopGate(t *testing.T) {
jobStart("测试", "x.mp4", 0, 100)
defer jobFinish("")
if err := jobPause(); err != nil {
t.Fatalf("暂停失败:%v", err)
}
if !jobSnapshot().Paused {
t.Fatal("暂停后状态里应为 paused")
}
// 暂停期间闸门会一直等,放到 goroutine 里,再恢复让它出来
done := make(chan error, 1)
go func() { done <- jobGate() }()
select {
case err := <-done:
t.Fatalf("暂停期间闸门不该放行,却返回了 %v", err)
case <-time.After(200 * time.Millisecond):
}
if err := jobResume(); err != nil {
t.Fatalf("恢复失败:%v", err)
}
select {
case err := <-done:
if err != nil {
t.Fatalf("恢复后闸门应放行,实际返回 %v", err)
}
case <-time.After(time.Second):
t.Fatal("恢复后闸门没有放行")
}
if err := jobStop(); err != nil {
t.Fatalf("停止失败:%v", err)
}
if err := jobGate(); !errors.Is(err, errJobStopped) {
t.Errorf("停止后闸门应返回 errJobStopped,实际 %v", err)
}
// 停止不是「失败」,界面要能区分开
jobFinish(errJobStopped.Error())
info := jobSnapshot()
if info.Active {
t.Error("结束后不该还是 active")
}
if info.Message != "已停止" || info.Err != errJobStopped.Error() {
t.Errorf("停止后的提示应为「已停止」,实际 message=%q err=%q", info.Message, info.Err)
}
if jobErrText(nil) != "" || jobErrText(errNoDanmu) != "" {
t.Error("正常结束和「没有弹幕」都不该算失败")
}
if jobErrText(errJobStopped) != errJobStopped.Error() {
t.Error("停止的原因要原样传给界面")
}
if jobErrText(errors.New("炸了")) != "炸了" {
t.Error("其它错误要原样传给界面")
}
}
// 没有任务时控制接口要明确报错,而不是静默成功
func TestJobControlWithoutTask(t *testing.T) {
jobFinish("")
if err := jobPause(); err == nil {
t.Error("没有任务时暂停应该报错")
}
if err := jobPause(); err == nil {
t.Error("没有任务时恢复应该报错")
}
if err := jobStop(); err == nil {
t.Error("没有任务时停止应该报错")
}
}
// ffmpeg 的 speed 字段形如 "1.37x",暂停或还没开始时是 "N/A"
func TestParseFFmpegSpeed(t *testing.T) {
cases := map[string]float64{
"1.37x": 1.37,
"2x": 2,
"N/A": 0,
"": 0,
"-1x": 0,
}
for in, want := range cases {
if got := parseFFmpegSpeed(in); got != want {
t.Errorf("parseFFmpegSpeed(%q) 应为 %v,实际 %v", in, want, got)
}
}
}
// 解析 -progress pipe:1 的输出,只在 progress= 行回调一次
func TestScanFFmpegProgress(t *testing.T) {
// out_time_us 和 out_time_ms 都是微秒(ffmpeg 的历史遗留)
text := strings.Join([]string{
"frame=100",
"fps=25.0",
"total_size=1048576",
"out_time_us=4000000",
"out_time_ms=4000000",
"out_time=00:00:04.000000",
"speed=1.37x",
"progress=continue",
"frame=200",
"out_time_us=8000000",
"total_size=2097152",
"speed=N/A",
"progress=end",
}, "\n")
type call struct {
sec, speed, fps float64
size int64
}
var got []call
scanFFmpegProgress(strings.NewReader(text), func(sec, speed, fps float64, size int64) {
got = append(got, call{sec, speed, fps, size})
})
if len(got) != 2 {
t.Fatalf("应该回调 2 次,实际 %d 次:%+v", len(got), got)
}
if got[0].sec != 4 || got[0].speed != 1.37 || got[0].fps != 25 || got[0].size != 1048576 {
t.Errorf("第一次回调不对:%+v", got[0])
}
if got[1].sec != 8 || got[1].size != 2097152 {
t.Errorf("第二次回调不对:%+v", got[1])
}
// 暂停时 speed 会是 N/A,这时不该把上一次的倍速顶掉
if got[1].speed != 0 {
t.Errorf("N/A 应该解析成 0,实际 %v", got[1].speed)
}
}
// 混着无关行和坏数据也不能崩
func TestScanFFmpegProgressTolerant(t *testing.T) {
text := "乱七八糟\nspeed=abc\nprogress=continue\nout_time_us=不是数字\nprogress=end\n"
n := 0
scanFFmpegProgress(strings.NewReader(text), func(sec, speed, fps float64, size int64) {
n++
if sec != 0 || speed != 0 {
t.Errorf("坏数据不该解析出值:sec=%v speed=%v", sec, speed)
}
})
if n != 2 {
t.Errorf("progress= 行都应回调,期望 2 次,实际 %d", n)
}
}
// 截图式地看一眼任务快照的字段都填上了
func TestJobSnapshotFields(t *testing.T) {
jobStart("手动合并", "成片.mp4", 123456, 3600)
defer jobFinish("")
jobSetPhase(jobPhaseAvatar)
jobAvatars(3, 10)
jobSetTotal(3600)
info := jobSnapshot()
if !info.Active || info.Kind != "手动合并" || info.OutName != "成片.mp4" {
t.Fatalf("基本信息不对:%+v", info)
}
if info.InputBytes != 123456 || info.TotalSec != 3600 {
t.Errorf("输入大小 / 总时长不对:%+v", info)
}
if info.AvatarDone != 3 || info.AvatarTotal != 10 {
t.Errorf("头像进度不对:%+v", info)
}
if len(info.Phases) != len(jobPhaseNames) {
t.Errorf("阶段列表长度应为 %d,实际 %d", len(jobPhaseNames), len(info.Phases))
}
if info.PhasePercent < 29.9 || info.PhasePercent > 30.1 {
t.Errorf("头像阶段进度应为 30%%,实际 %v", info.PhasePercent)
}
if !info.Pausable {
t.Error("任务在跑的时候应该可暂停")
}
}