Files
acfunlive-remake/progress_test.go
T
tamakyi 7dd2487cb8 合并任务的可视化进度、预计剩余时间与暂停/停止
用户反馈:合并的进度只体现在日志里(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 全过。
2026-09-26 02:05:47 +08:00

309 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("任务在跑的时候应该可暂停")
}
}