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

用户反馈:合并的进度只体现在日志里(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
+68 -16
View File
@@ -5,6 +5,7 @@ import (
"bufio"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
@@ -117,7 +118,23 @@ func prepareForMerge(ffmpegFile string, segs []recordSegment, jsonlFile string,
dir := filepath.Dir(jsonlFile)
// 拼接出来的中间文件沿用分段的封装格式,concat 用 -c copy 时容器一致最稳
joined := filepath.Join(dir, chunkTempPrefix+"joined"+filepath.Ext(files[0]))
if err := concatChunks(ffmpegFile, files, joined, dir); err != nil {
jobSetPhase(jobPhaseJoin)
joinedSec := 0.0
for _, d := range durs {
joinedSec += d
}
jobSetTotal(joinedSec)
if err := concatChunks(ffmpegFile, files, joined, dir, func(outSec, speed, fps float64, size int64) {
if joinedSec > 0 {
jobSetPhasePercent(outSec / joinedSec)
}
}); err != nil {
// 任务被手动停止时不要再退化成「只合并第一个分段」,
// 也别把拼到一半的中间文件留下
if jobStopRequested() {
_ = os.Remove(joined)
return "", "", 0, noop, errJobStopped
}
lPrintWarnf("拼接录播分段失败,本场只合并第一个分段(后半场没有弹幕):%v", err)
offset := float64(danmuStartNs-starts[0]) / 1e9
return files[0], jsonlFile, offset, noop, nil
@@ -225,8 +242,29 @@ func remapJsonlTimes(srcFile, dstFile string, starts []int64, durs []float64, da
return nil
}
// 执行自动合并及后续的删除/移动
// 自动合并入口:把结果写进任务进度;日志措辞仍由内部负责
func autoMerge(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int64) {
err := autoMergeJob(uid, segs, jsonlFile, danmuStartNs)
if err == errJobStopped {
// 停止不是故障,内部不打印,这里补一句
lPrintWarn("自动合并已被手动停止" + longID(uid))
}
jobFinish(jobErrText(err))
}
// 把错误翻成给界面看的原因:正常结束和「没有弹幕」都不算失败
func jobErrText(err error) string {
switch err {
case nil, errNoDanmu:
return ""
case errJobStopped:
return errJobStopped.Error()
}
return err.Error()
}
// 执行自动合并及后续的删除/移动。日志都在内部按原措辞打印,返回值只给上层写进度用
func autoMergeJob(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int64) error {
lPrintln("开始自动合并" + longID(uid) + "的录播和弹幕层")
// 视频和弹幕是一次录制产生的,文件名去掉扩展名后应该一致(扩展名由 recordFormat 决定)
@@ -235,21 +273,14 @@ func autoMerge(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int
jsonlBase := filepath.Base(strings.TrimSuffix(jsonlFile, ".jsonl"))
if videoBase != jsonlBase {
lPrintWarnf("视频文件(%s)和弹幕文件(%s)不是同一场录制,取消自动合并", segs[0].file, jsonlFile)
return
return errors.New("视频文件和弹幕文件不是同一场录制")
}
}
ffmpegFile := getFFmpeg()
if ffmpegFile == "" {
lPrintErr("自动合并失败:找不到 ffmpeg,请安装并加入 PATH,或在 web UI 设置里填写 ffmpeg 路径")
return
}
video, jsonl, offset, cleanup, err := prepareForMerge(ffmpegFile, segs, jsonlFile, danmuStartNs)
defer cleanup()
if err != nil {
lPrintErrf("自动合并失败:%v", err)
return
return errors.New("找不到 ffmpeg,请安装并加入 PATH,或在 web UI 设置里填写 ffmpeg 路径")
}
// 成片先在本地合成,再落到成片文件夹:成片目录可能是网络盘或外接盘,
@@ -257,28 +288,48 @@ func autoMerge(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int
mergeDir := mergeSaveDir()
if err := os.MkdirAll(mergeDir, 0755); err != nil {
lPrintWarnf("创建成片文件夹 %s 失败:%v", mergeDir, err)
return
return fmt.Errorf("创建成片文件夹 %s 失败:%v", mergeDir, err)
}
outFile := filepath.Join(mergeDir, filepath.Base(strings.TrimSuffix(jsonlFile, ".jsonl")+"_弹幕合并.mp4"))
tmpOut := tempMergeOutput(jsonlFile, filepath.Base(outFile))
// 输入大小按各分段实际位置合计(录制结束时文件可能已被移动到保存目录)
inputBytes := int64(0)
for _, s := range segs {
if p := findMovedFile(s.file); p != "" {
inputBytes += fileSizeOrZero(p)
}
}
jobStart("自动合并", filepath.Base(outFile), inputBytes, 0)
video, jsonl, offset, cleanup, err := prepareForMerge(ffmpegFile, segs, jsonlFile, danmuStartNs)
defer cleanup()
if err != nil {
lPrintErrf("自动合并失败:%v", err)
return err
}
// 标记成正在写入,避免在 web UI 的文件管理里把还没写完的成片删掉
markFileActive(tmpOut, true)
burnErr := burnDanmu(jsonl, video, tmpOut, offset)
markFileActive(tmpOut, false)
if burnErr != nil {
_ = os.Remove(tmpOut)
if burnErr == errNoDanmu {
switch burnErr {
case errJobStopped:
return errJobStopped
case errNoDanmu:
lPrintWarn("弹幕文件里没有弹幕,跳过自动合并")
return
return errNoDanmu
}
lPrintErrf("自动合并失败:%v", burnErr)
return
return burnErr
}
jobSetPhase(jobPhasePlace)
if err := placeOutput(tmpOut, outFile); err != nil {
_ = os.Remove(tmpOut)
lPrintErrf("自动合并失败:%v", err)
return
return err
}
lPrintln("自动合并完成:" + outFile)
@@ -307,4 +358,5 @@ func autoMerge(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int
}
}
}
return nil
}