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

用户反馈:合并的进度只体现在日志里(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
+14 -1
View File
@@ -166,13 +166,26 @@ web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`),
| 分区 | 内容 |
| --- | --- |
| 监控 | 添加主播、主播列表(默认打开的分区),以及运行日志 |
| 监控 | 当前转换任务的进度卡片、添加主播、主播列表(默认打开的分区),以及运行日志 |
| 录制 | 保存目录和子文件夹、录播格式、默认码率上限、ffmpeg 路径 |
| 合并 | 自动合并开关、成片输出目录和合成码率、手动合并 |
| 通知 | 推送哪些事件,以及 QQ 机器人 / MeoW 两个渠道的配置和测试连通 |
| 文件 | 磁盘剩余容量、录播和成片的文件列表 |
| 设置 | 是否允许外网访问、管理口令的查看与更换 |
### 转换任务进度
自动合并和手动合并跑起来之后,`监控`分区顶部会出现一张`转换任务`卡片,页面最顶上还有一条很细的全局进度条,切到哪个分区都能看到。卡片里给的是:
- **阶段步骤条**:准备 → 分段拼接 → 获取头像 → 画面渲染 → 拼接与落盘,走过的打勾、当前的高亮(暂停时变红);
- **总进度条和百分比**:渲染阶段按「已渲染时长 ÷ 视频总时长」算,其余阶段按各自的完成比例折算后加权;
- **实时指标**:渲染倍速、帧率、已处理时长 / 总时长、已用时间、已写出多少;
- **预计剩余时间**:渲染阶段用「剩余时长 ÷ 当前倍速」,别的阶段按已用时间线性外推;
- **体积对比条**:源片大小和预计成片大小并排显示两段,预计值先按目标码率估,渲染过 5% 之后换成按已写出字节外推(这时候标注会从`按目标码率估算`变成`按已渲染部分推算`);
- **渲染速度折线**:最近两分钟、每秒一个采样点,能看出硬件编码和 CPU 渲染有没有被别的任务拖慢。
任务跑着的时候可以直接**暂停**和**停止**:暂停会挂起 ffmpeg 进程(Windows 用`NtSuspendProcess`、Linux 用`SIGSTOP`),CPU 占用归零、进度冻结,点`继续`从原地接着跑;不在 ffmpeg 环节(比如正在下载头像)时,暂停和停止会在下一个检查点生效。停止会中断这次合并并**丢弃已经渲染的部分**——成片目录不会留下半成品,录播目录里的分块临时文件也会一并清掉,所以需要重新跑一次。
开关和下拉框改完立即保存;文本框(目录、地址、口令这些)填完按回车或点右上角的`保存设置`才生效,这一条在每个有文本框的分区里也写了提示。
目录和文件两条路都行:直接在框里填,或者点框右边的`浏览`逐级点选。弹窗里可以一路点进子文件夹、点`C:`/`D:`这类按钮(Linux 上是`/`、`/home`、`/mnt` 这些常用位置)换根位置,地址栏本身也能粘贴路径回车跳转,随时在点选和手填之间切换;选好点`选用这个文件夹`填回设置框,仍然是按回车或点保存才生效。填的是文件时(比如 ffmpeg 路径)列出的是文件,双击即选中并填入。默认不显示以点开头的隐藏项,需要时勾上`显示隐藏项`。
+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
}
+165 -15
View File
@@ -64,6 +64,10 @@ const maxDanmuPerChunk = 80
// 分块渲染产生的临时文件前缀
const chunkTempPrefix = "acfunlive_chunk_"
// 预估成片体积时给音频留的码率(Kbps):分块时音频固定重编码成 192k,
// 单块时是直接拷流,这里统一按 192 估,反正渲染过 5% 后会被实测外推值顶掉
const estimateAudioKbps = 192
// 一个待渲染的弹幕块
type danmuChunk struct {
start float64 // 在视频时间轴上的起点(秒)
@@ -300,7 +304,13 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
}
}
looked := 0
jobSetPhase(jobPhaseAvatar)
jobAvatars(0, len(uids))
for i, uid := range uids {
// 这一阶段既要下网络又要解图片,允许在这里暂停 / 停止
if err := jobGate(); err != nil {
return err
}
url := urlByUID[uid]
if url == "" {
// 从 .ass 还原出来的弹幕没有头像链接,按 uid 反查一次
@@ -311,6 +321,7 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
lPrintf("正在获取头像:已处理 %d/%d 个用户", i+1, len(uids))
}
}
jobAvatars(i+1, len(uids))
if url == "" {
continue
}
@@ -348,6 +359,21 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
}
chunks := splitDanmuChunks(records, offset, videoDuration)
// 任务总时长:有视频就用视频时长,透明头像层模式没有视频,用各块时长之和
jobTotalSec := videoDuration
if jobTotalSec <= 0 {
for _, ch := range chunks {
jobTotalSec += ctx.chunkDuration(ch)
}
}
jobSetTotal(jobTotalSec)
// 预计成片体积 =(视频码率 + 音频码率)× 时长 ÷ 8;渲染过 5% 之后会被实测外推值顶掉
if bitrate > 0 {
jobPlanSize(int64(float64(bitrate+estimateAudioKbps) * jobTotalSec / 8 * 1000))
}
jobChunks(0, len(chunks))
jobSetPhase(jobPhaseRender)
if len(chunks) <= 1 {
lPrintf("共 %d 条弹幕,%d 个用户的头像可用,开始合成", len(records), len(avatarPaths))
return ctx.burnSingle(records)
@@ -518,7 +544,10 @@ func (c *burnContext) burnSingle(records []danmuRecord) error {
}
defer os.Remove(assPath)
return c.renderChunk(chunk, assName, c.outFile, false)
// 只有一块,ffmpeg 报的时长就是整片进度
return c.renderChunk(chunk, assName, c.outFile, false, func(outSec, speed, fps float64, size int64) {
jobRender(outSec, speed, fps, size)
})
}
// 分块渲染:每块单独渲染成一个小片,最后用 concat 拼成完整成片
@@ -536,7 +565,18 @@ func (c *burnContext) burnChunked(chunks []danmuChunk) error {
}()
ad, gap := avatarSize(c.meta.FontSize), avatarGap(c.meta.FontSize)
// 已完成块写出的字节数:加在当前块上,界面上的「已写出」和体积外推才是累计值
var bytesBase int64
var joinedSec float64
for _, ch := range chunks {
joinedSec += c.chunkDuration(ch)
}
for i, ch := range chunks {
// 每块开始时过一下闸门:暂停在这里等,停止在这里直接退出
if err := jobGate(); err != nil {
return err
}
jobChunks(i, len(chunks))
// 每块的字幕单独生成,只包含这一块时间范围内的弹幕
assName := fmt.Sprintf("%schunk_%04d.ass", chunkTempPrefix, i)
assPath := filepath.Join(c.dir, assName)
@@ -546,16 +586,32 @@ func (c *burnContext) burnChunked(chunks []danmuChunk) error {
defer os.Remove(assPath)
part := filepath.Join(c.dir, fmt.Sprintf("%spart_%04d%s", chunkTempPrefix, i, ext))
// 先登记再渲染:中途失败或被打断时,defer 里的清理也能把这个半成品删掉
parts = append(parts, part)
lPrintf("正在渲染第 %d/%d 块(%s ~ %s,%d 条弹幕)",
i+1, len(chunks), formatSeconds(ch.start), formatSeconds(ch.end), len(ch.records))
if err := c.renderChunk(ch, assName, part, true); err != nil {
base := bytesBase
if err := c.renderChunk(ch, assName, part, true, func(outSec, speed, fps float64, size int64) {
// 块内时间加上块起点,折算成整片时间轴上的进度
jobRender(ch.start+outSec, speed, fps, base+size)
}); err != nil {
if jobStopRequested() {
return errJobStopped
}
return fmt.Errorf("渲染第 %d/%d 块失败:%v", i+1, len(chunks), err)
}
parts = append(parts, part)
if n := fileSizeOrZero(part); n > 0 {
bytesBase += n
}
}
jobSetPhase(jobPhasePlace)
lPrintf("正在把 %d 个分块拼接成完整成片", len(parts))
if err := concatChunks(c.ffmpegFile, parts, c.outFile, c.dir); err != nil {
if err := concatChunks(c.ffmpegFile, parts, c.outFile, c.dir, func(outSec, speed, fps float64, size int64) {
if joinedSec > 0 {
jobSetPhasePercent(outSec / joinedSec)
}
}); err != nil {
return err
}
return nil
@@ -633,7 +689,8 @@ func writeChunkAss(path string, meta danmuMeta, chunk danmuChunk, offset float64
// 渲染一个块:把这一块的弹幕(文字层 + 头像层)压进对应的视频片段
// cut 为 true 时表示这是分块渲染,需要按时长切出视频片段并重编码音频
func (c *burnContext) renderChunk(chunk danmuChunk, assBase, out string, cut bool) error {
// cb 用来回传 ffmpeg 的实时进度(已输出时长 / 倍速 / 帧率 / 已写出字节),可为 nil
func (c *burnContext) renderChunk(chunk danmuChunk, assBase, out string, cut bool, cb ffmpegProgress) error {
burnMode := c.videoFile != ""
// 统计这一块用到的头像,按首次出现顺序分配输入序号(输入 0 是视频或背景)
@@ -743,7 +800,7 @@ func (c *burnContext) renderChunk(chunk danmuChunk, assBase, out string, cut boo
// 透明头像层模式固定用 ProRes 4444,不能再追加编码器覆盖
full := append(append([]string{}, args...), out)
lPrintln("开始运行 ffmpeg 合成透明头像层,输出到 " + out)
if err := runFFmpeg(c.ffmpegFile, full, c.dir); err != nil {
if err := runFFmpegWith(c.ffmpegFile, full, c.dir, cb); err != nil {
return err
}
return nil
@@ -770,7 +827,11 @@ func (c *burnContext) renderChunk(chunk danmuChunk, assBase, out string, cut boo
if i == begin {
lPrintf("开始运行 ffmpeg 合成(编码器 %s),输出到 %s", enc.name, out)
}
if err := runFFmpeg(c.ffmpegFile, full, c.dir); err != nil {
if err := runFFmpegWith(c.ffmpegFile, full, c.dir, cb); err != nil {
// 停止任务时杀掉 ffmpeg 也会让它非 0 退出,这时候不该再换编码器重试
if jobStopRequested() {
return errJobStopped
}
lastErr = err
if i+1 < len(encoders) {
lPrintWarnf("编码器 %s 运行失败,回退到 %s:%v", enc.name, encoders[i+1].name, err)
@@ -859,16 +920,97 @@ func buildEncoders(bitrate int, ffmpegFile string) []videoEncoder {
}
// 运行 ffmpeg,输出直接透传到控制台
// ffmpeg 的进度回调:已输出时长(秒)、实时倍速、帧率、已写出字节
type ffmpegProgress func(outSec, speed, fps float64, totalSize int64)
func runFFmpeg(ffmpegFile string, args []string, dir string) error {
cmd := exec.Command(ffmpegFile, args...)
cmd.Dir = dir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
return runFFmpegWith(ffmpegFile, args, dir, nil)
}
// 用 concat demuxer 把各分块的成片拼接起来
func concatChunks(ffmpegFile string, files []string, out, dir string) error {
// 跑一次 ffmpeg。cb 不为空时加 -progress pipe:1 取结构化进度;
// ffmpeg 自己的统计行仍在 stderr 里,照旧进日志,单独用命令行时不会少东西。
func runFFmpegWith(ffmpegFile string, args []string, dir string, cb ffmpegProgress) error {
// 暂停时在这里等,任务被停止时直接返回,不再启动新进程
if err := jobGate(); err != nil {
return err
}
full := args
if cb != nil {
full = append([]string{"-progress", "pipe:1"}, args...)
}
cmd := exec.Command(ffmpegFile, full...)
cmd.Dir = dir
cmd.Stderr = os.Stderr
if cb != nil {
// StdoutPipe 要求 Stdout 还没被设置,所以这里不能先指到 os.Stdout
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
jobAttach(cmd)
defer jobDetach(cmd)
// 读到 EOF(进程退出会关闭管道)再 Wait,避免和 Wait 抢着关管道
scanFFmpegProgress(stdout, cb)
return cmd.Wait()
}
cmd.Stdout = os.Stdout
if err := cmd.Start(); err != nil {
return err
}
jobAttach(cmd)
defer jobDetach(cmd)
return cmd.Wait()
}
// 解析 -progress pipe:1 的输出:每行一个 key=value,
// 每个块以 progress=continue / progress=end 收尾,就在这时候回调一次。
func scanFFmpegProgress(r io.Reader, cb ffmpegProgress) {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 4096), 1<<20)
var doneSec, speed, fps float64
var size int64
for sc.Scan() {
key, val, ok := strings.Cut(strings.TrimSpace(sc.Text()), "=")
if !ok {
continue
}
switch key {
case "out_time_us", "out_time_ms":
// 这两个键都是微秒(ffmpeg 的历史遗留),都认一下,谁在后面谁说了算
if us, err := strconv.ParseInt(val, 10, 64); err == nil && us >= 0 {
doneSec = float64(us) / 1e6
}
case "speed":
speed = parseFFmpegSpeed(val)
case "fps":
if v, err := strconv.ParseFloat(val, 64); err == nil && v >= 0 {
fps = v
}
case "total_size":
if v, err := strconv.ParseInt(val, 10, 64); err == nil && v >= 0 {
size = v
}
case "progress":
cb(doneSec, speed, fps, size)
}
}
}
// 解析 ffmpeg 的 speed 字段,形如 "1.37x";还没开始或暂停时是 "N/A"
func parseFFmpegSpeed(v string) float64 {
v = strings.TrimSuffix(strings.TrimSpace(v), "x")
f, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
if err != nil || f < 0 {
return 0
}
return f
}
// 用 concat demuxer 把各分块的成片拼接起来。cb 用来回传拼接进度,可为 nil
func concatChunks(ffmpegFile string, files []string, out, dir string, cb ffmpegProgress) error {
listFile := filepath.Join(dir, chunkTempPrefix+"list.txt")
var b strings.Builder
for _, p := range files {
@@ -881,12 +1023,20 @@ func concatChunks(ffmpegFile string, files []string, out, dir string) error {
}
defer os.Remove(listFile)
if err := runFFmpeg(ffmpegFile, []string{"-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c", "copy", out}, dir); err != nil {
if err := runFFmpegWith(ffmpegFile, []string{"-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c", "copy", out}, dir, cb); err != nil {
return fmt.Errorf("拼接各分块失败:%v", err)
}
return nil
}
// 取文件大小,出错时返回 0
func fileSizeOrZero(path string) int64 {
if info, err := os.Stat(path); err == nil {
return info.Size()
}
return 0
}
// 把秒格式化成 ffmpeg 参数,保留毫秒
func formatSeconds(sec float64) string {
return strconv.FormatFloat(sec, 'f', 3, 64)
+18
View File
@@ -103,6 +103,24 @@ web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全
合并状态通过`GET /api/status`查看:`mergeBusy`(是否在跑)、`mergeOutName`(当前成片名)、`mergeErr`(失败原因)、`mergeDoneAt`(上次完成时间)。合并期间成片会出现在`GET /api/files`里且`inUse`为 true,此时`POST /api/file/remove`会返回 409 拒绝删除。
`GET /api/job` 返回转换任务(自动合并和手动合并共用一份)的实时进度,页面每秒轮一次它来画监控分区顶部的进度卡片和全局细进度条;空闲时返回`active: false`,但会保留上一次任务的结束信息(`doneAt`、`message`、`err`)方便界面把结果多显示一会儿。字段:
| 字段 | 含义 |
| --- | --- |
| `kind` / `outName` | 任务类型(`手动合并` / `自动合并`)和成片文件名 |
| `phases` / `phaseIdx` / `phasePercent` | 阶段名列表、当前阶段序号、当前阶段内的完成比例 |
| `percent` | 总进度 0~100,按各阶段权重折算(渲染占 0.75) |
| `paused` / `stopping` / `pausable` | 是否已暂停 / 是否正在停止 / 现在能不能暂停 |
| `speed` / `fps` / `doneSec` / `totalSec` | 渲染倍速、帧率、已渲染时长、视频总时长 |
| `chunkDone` / `chunkTotal` | 分块渲染的进度(`第 3/12 块`) |
| `avatarDone` / `avatarTotal` | 获取头像阶段的用户数进度 |
| `inputBytes` / `estOutBytes` / `estSource` / `outBytes` | 源片总体积、预计成片体积及其来源(`bitrate` 按码率估 / `measured` 按已渲染部分外推)、已写出字节 |
| `etaSec` | 预计剩余秒数,算不出来时为 0 |
| `samples` | 渲染速度折线的采样点(约每秒一个,最多 120 个) |
| `message` / `err` / `doneAt` | 结束提示(`已完成` / `已停止` / `失败`)、失败原因、结束时刻 |
`POST /api/job/control` 控制当前任务,请求体`{"action":"pause"}`、`{"action":"resume"}`或`{"action":"stop"}`,成功返回`{"ok":true}`。没有任务在跑时返回 409,未知的 action 返回 400。暂停会挂起 ffmpeg 进程(Windows 用`NtSuspendProcess`、Linux 用`SIGSTOP`),CPU 占用归零;不在 ffmpeg 环节时暂停和停止会在下一个检查点生效。**停止会丢弃已经渲染的部分**:不落成片、清掉分块临时文件,任务以`message: "已停止"`结束(`err`为`任务已被手动停止`,界面据此和真失败区分开)。
`POST /api/config`里的`danmuFontSize`控制合并时弹幕文字的字号(像素),头像按同一比例缩放,填 0 表示按画面分辨率自动:字号取画面**短边**的 5.56%(横屏取高、竖屏取宽),所以 1920x1080 是 60、1280x720 是 40、720x1280 也是 40 —— 竖屏画面窄,若按高度算会得到 71,一条弹幕就铺满整行。显式填写则按填的像素值直接用。渲染前会按最终字号重排弹道:从上游原版`.ass`还原出来的弹幕只按内容宽度占位,而渲染用的是「昵称(uid):内容 + 头像」的完整宽度,占位时间更长,照搬原弹道会追尾叠字;重排按完整宽度重新分配,同一条弹道上不会再有两条弹幕叠在一起。
`mergeBitrate`填 0(默认)时按「源片码率 + 500 Kbps」自动计算:先探测视频流的码率,加 500 留给烧进去的弹幕;探测不到就退回质量模式。填大于 0 的值则按该码率固定输出。编码器优先硬件 HEVC(`hevc_qsv`,同画质比 H.264 省约三成体积,mp4 里打`hvc1`标记),不支持时回退`h264_qsv`、`libx264`,日志里会打印实际用的那一个。
+23
View File
@@ -0,0 +1,23 @@
//go:build !windows
package main
import (
"os"
"syscall"
)
// 挂起进程:SIGSTOP,进程立刻停止占用 CPU,SIGCONT 后从原地继续
func suspendProcess(p *os.Process) error {
if p == nil {
return nil
}
return syscall.Kill(p.Pid, syscall.SIGSTOP)
}
func resumeProcess(p *os.Process) error {
if p == nil {
return nil
}
return syscall.Kill(p.Pid, syscall.SIGCONT)
}
+44
View File
@@ -0,0 +1,44 @@
//go:build windows
package main
import (
"fmt"
"os"
"syscall"
)
// PROCESS_SUSPEND_RESUME:挂起 / 恢复进程所需的访问权限
const processSuspendResume = 0x0800
var (
ntdllDLL = syscall.NewLazyDLL("ntdll.dll")
procSuspend = ntdllDLL.NewProc("NtSuspendProcess")
procResume = ntdllDLL.NewProc("NtResumeProcess")
)
// 挂起进程。Windows 没有公开的 Win32 API,用 ntdll 的 NtSuspendProcess,
// 效果等同任务管理器里的「挂起」:进程不再占用 CPU,恢复后从原地接着跑。
func suspendProcess(p *os.Process) error {
return ntProcessCall(procSuspend, "NtSuspendProcess", p)
}
func resumeProcess(p *os.Process) error {
return ntProcessCall(procResume, "NtResumeProcess", p)
}
func ntProcessCall(proc *syscall.LazyProc, name string, p *os.Process) error {
if p == nil {
return nil
}
h, err := syscall.OpenProcess(processSuspendResume, false, uint32(p.Pid))
if err != nil {
return fmt.Errorf("打开进程 %d 失败:%v", p.Pid, err)
}
defer syscall.CloseHandle(h)
// NTSTATUS 为 0 表示成功
if ret, _, _ := proc.Call(uintptr(h)); ret != 0 {
return fmt.Errorf("%s 失败,NTSTATUS=0x%x", name, ret)
}
return nil
}
+32 -13
View File
@@ -459,13 +459,20 @@ func buildMergePlan(req mergeRequest) (mergePlan, error) {
return plan, nil
}
// 真正执行合并,进度直接打到运行日志里
// 真正执行合并,进度既打到运行日志里,也同步给 web UI 的任务卡片
func runManualMerge(plan mergePlan) error {
ffmpegFile := getFFmpeg()
if ffmpegFile == "" {
return errors.New("找不到 ffmpeg,请先在设置里填写 ffmpeg 路径")
}
// 先把任务的输入大小和总时长登记下来,界面在 ffmpeg 跑起来之前就有东西可显示
inputBytes := int64(0)
for _, v := range plan.info.Videos {
inputBytes += v.Size
}
jobStart("手动合并", plan.info.OutName, inputBytes, plan.info.Duration)
src, err := prepareDanmuSource(plan.danmu, true)
if err != nil {
return err
@@ -501,7 +508,6 @@ func runManualMerge(plan mergePlan) error {
if err != nil {
return err
}
offset, note := autoOffset, "按文件名里的录制时间自动推算"
if plan.offset != nil {
offset, note = *plan.offset, "按你填写的偏移"
@@ -532,6 +538,7 @@ func runManualMerge(plan mergePlan) error {
_ = os.Remove(tmpOut)
return burnErr
}
jobSetPhase(jobPhasePlace)
if err := placeOutput(tmpOut, plan.out); err != nil {
_ = os.Remove(tmpOut)
return err
@@ -601,18 +608,30 @@ func mergeHandler(w http.ResponseWriter, r *http.Request) {
mergeRun.doneAt = time.Now().Unix()
mergeRun.Unlock()
}()
if err := runManualMerge(plan); err != nil {
msg := err.Error()
if err == errNoDanmu {
msg = "弹幕文件里没有弹幕"
lPrintWarn("弹幕文件里没有弹幕,取消手动合并")
} else {
lPrintErrf("手动合并失败:%v", err)
}
mergeRun.Lock()
mergeRun.lastErr = msg
mergeRun.Unlock()
err := runManualMerge(plan)
// 任务卡片挂在这条链路上,无论成败都要收尾,否则进度会一直留在界面上
jobFinish(jobErrText(err))
if err == nil {
return
}
msg := err.Error()
switch err {
case errNoDanmu:
msg = "弹幕文件里没有弹幕"
lPrintWarn("弹幕文件里没有弹幕,取消手动合并")
case errJobStopped:
// 手动停止不是失败,别让界面弹「合并失败」
lPrintWarn("手动合并已被手动停止")
mergeRun.Lock()
mergeRun.lastErr = ""
mergeRun.Unlock()
return
default:
lPrintErrf("手动合并失败:%v", err)
}
mergeRun.Lock()
mergeRun.lastErr = msg
mergeRun.Unlock()
})
w.Header().Set("Content-Type", "application/json")
+517
View File
@@ -0,0 +1,517 @@
// 转换任务(合并 / 烧录弹幕)的实时进度与暂停、停止控制。
// 进度只保留一份全局状态:同一时间只允许一个合并任务在跑(mergeRun 已经拦住了)。
package main
import (
"encoding/json"
"errors"
"math"
"net/http"
"os/exec"
"sync"
"time"
)
// 任务阶段,前端按这个顺序画步骤条
var jobPhaseNames = []string{"准备", "分段拼接", "获取头像", "画面渲染", "拼接与落盘"}
// 阶段序号,和 jobPhaseNames 一一对应
const (
jobPhasePrepare = iota // 准备(读弹幕、探测视频、生成字幕)
jobPhaseJoin // 分段拼接(断流分成多段时才有事做)
jobPhaseAvatar // 获取头像
jobPhaseRender // 画面渲染(最耗时)
jobPhasePlace // 拼接分块与落盘
)
// 各阶段在总进度里占的权重(加起来是 1)。渲染最耗时,权重给得最多;
// 其余阶段按经验估:拼接分段与头像下载是分钟级,准备和落盘是秒级。
var jobPhaseWeights = []float64{0.02, 0.08, 0.10, 0.75, 0.05}
// 渲染进度的采样间隔与折线最多保留的点数(1 秒 1 个,够画两分钟)
const (
jobSampleInterval = time.Second
jobMaxSamples = 120
)
// 任务被手动停止时各层返回这个错误:调用方据此清理临时文件,不再回退其它编码器
var errJobStopped = errors.New("任务已被手动停止")
// 速度折线上的一个点
type jobSample struct {
T float64 `json:"t"` // 任务开始后的秒数
Speed float64 `json:"speed"` // 当时的渲染倍速
}
// 给前端的任务快照
type jobInfo struct {
Active bool `json:"active"`
Kind string `json:"kind"` // 手动合并 / 自动合并
OutName string `json:"outName"` // 成片文件名
PhaseIdx int `json:"phaseIdx"` // 当前阶段序号,-1 表示已全部完成
Phases []string `json:"phases"` // 阶段名列表
PhasePercent float64 `json:"phasePercent"`
Percent float64 `json:"percent"` // 总进度 0~100
Paused bool `json:"paused"`
Stopping bool `json:"stopping"`
Speed float64 `json:"speed"` // 渲染倍速,例如 1.37
FPS float64 `json:"fps"`
DoneSec float64 `json:"doneSec"` // 已渲染时长
TotalSec float64 `json:"totalSec"` // 视频总时长
ChunkDone int `json:"chunkDone"`
ChunkTotal int `json:"chunkTotal"`
AvatarDone int `json:"avatarDone"`
AvatarTotal int `json:"avatarTotal"`
InputBytes int64 `json:"inputBytes"` // 源视频总大小
EstOutBytes int64 `json:"estOutBytes"` // 预计成片大小
EstSource string `json:"estSource"` // 预计值的来源:bitrate / measured
OutBytes int64 `json:"outBytes"` // 已经写出多少字节
ETASec float64 `json:"etaSec"` // 预计剩余秒数,0 表示算不出来
ElapsedSec float64 `json:"elapsedSec"` // 已用时间
Samples []jobSample `json:"samples"` // 速度折线
Message string `json:"message"` // 结束后的提示(完成 / 失败原因)
Err string `json:"err"` // 失败原因,成功为空
DoneAt int64 `json:"doneAt"` // 结束时刻(Unix 秒)
Pausable bool `json:"pausable"` // 现在是否能暂停
}
// 全局唯一的任务状态
type jobState struct {
sync.Mutex
active bool
kind string
outName string
phaseIdx int
phasePct float64
started time.Time
paused bool
stopped bool
proc *exec.Cmd // 当前在跑的 ffmpeg,暂停/停止都作用在它身上
totalSec float64
doneSec float64
speed float64
fps float64
chunkDone int
chunkTotal int
avatarDone int
avatarTotal int
inputBytes int64
// 按目标码率算出来的预计成片大小
planOutBytes int64
// 按「已写出字节 ÷ 已渲染比例」外推出来的预计成片大小,渲染到 5% 后才有值
liveOutBytes int64
outBytes int64
samples []jobSample
lastSample time.Time
errText string
message string
doneAt int64
}
var job jobState
// 开始一个新任务。kind 是任务类型,totalSec 是视频总时长(拿不到填 0)
func jobStart(kind, outName string, inputBytes int64, totalSec float64) {
job.Lock()
defer job.Unlock()
job.active = true
job.kind = kind
job.outName = outName
job.phaseIdx = 0
job.phasePct = 0
job.started = time.Now()
job.paused = false
job.stopped = false
job.proc = nil
job.totalSec = totalSec
job.doneSec = 0
job.speed = 0
job.fps = 0
job.chunkDone, job.chunkTotal = 0, 0
job.avatarDone, job.avatarTotal = 0, 0
job.inputBytes = inputBytes
job.planOutBytes = 0
job.liveOutBytes = 0
job.outBytes = 0
job.samples = nil
job.lastSample = time.Time{}
job.errText = ""
job.message = ""
job.doneAt = 0
}
// 任务结束。errText 为空表示成功,errJobStopped.Error() 表示被手动停止
func jobFinish(errText string) {
job.Lock()
defer job.Unlock()
job.active = false
job.paused = false
job.stopped = false
job.proc = nil
job.speed = 0
job.errText = errText
job.doneAt = time.Now().Unix()
switch errText {
case "":
job.phaseIdx = len(jobPhaseNames)
job.phasePct = 1
job.message = "已完成"
case errJobStopped.Error():
job.message = "已停止"
default:
job.message = "失败"
}
}
// 任务是否正在跑
func jobActive() bool {
job.Lock()
defer job.Unlock()
return job.active
}
// 是否已经被要求停止
func jobStopRequested() bool {
job.Lock()
defer job.Unlock()
return job.stopped
}
// 各阶段在进入下一步前调用这个「闸门」:暂停时在这里等,被停止时立刻返回错误。
// 放在循环体里,这样下载头像、逐块渲染这些阶段都能被暂停和停止。
func jobGate() error {
for {
job.Lock()
stopped, paused := job.stopped, job.paused
job.Unlock()
if stopped {
return errJobStopped
}
if !paused {
return nil
}
time.Sleep(120 * time.Millisecond)
}
}
// 切换阶段。序号只往前走,重复设置同一阶段不会把进度打回 0
func jobSetPhase(idx int) {
job.Lock()
defer job.Unlock()
if !job.active {
return
}
if idx > job.phaseIdx {
job.phaseIdx = idx
job.phasePct = 0
}
}
// 直接设置当前阶段的完成比例(0~1),只取更大值,避免折返
func jobSetPhasePercent(p float64) {
job.Lock()
defer job.Unlock()
if !job.active {
return
}
if p > job.phasePct {
job.phasePct = clamp01(p)
}
}
// 渲染进度:doneSec 是累计已渲染的秒数(分块时已折算成整片时间轴),
// cumBytes 是累计已写出的字节数(分块时是「已完成块 + 当前块」)
func jobRender(doneSec, speed, fps float64, cumBytes int64) {
job.Lock()
defer job.Unlock()
if !job.active {
return
}
if doneSec > job.doneSec {
job.doneSec = doneSec
}
if speed > 0 {
job.speed = speed
}
if fps > 0 {
job.fps = fps
}
if cumBytes > job.outBytes {
job.outBytes = cumBytes
}
ratio := 0.0
if job.totalSec > 0 {
ratio = clamp01(job.doneSec / job.totalSec)
}
if ratio > job.phasePct {
job.phasePct = ratio
}
// 渲染到 5% 之后,用「已写出 ÷ 已完成比例」外推整片体积,比按码率估的准
if ratio >= 0.05 && job.outBytes > 0 {
if est := int64(float64(job.outBytes) / ratio); est > 0 {
job.liveOutBytes = est
}
}
// 速度折线:最多 1 秒一个点
now := time.Now()
if job.lastSample.IsZero() || now.Sub(job.lastSample) >= jobSampleInterval {
job.lastSample = now
job.samples = append(job.samples, jobSample{T: now.Sub(job.started).Seconds(), Speed: speed})
if len(job.samples) > jobMaxSamples {
job.samples = append([]jobSample(nil), job.samples[len(job.samples)-jobMaxSamples:]...)
}
}
}
// 已分块渲染的块数,给界面显示「第 3/12 块」
func jobChunks(done, total int) {
job.Lock()
defer job.Unlock()
if !job.active {
return
}
job.chunkDone, job.chunkTotal = done, total
}
// 头像下载进度(按用户数)
func jobAvatars(done, total int) {
job.Lock()
defer job.Unlock()
if !job.active {
return
}
job.avatarDone, job.avatarTotal = done, total
if total > 0 {
p := float64(done) / float64(total)
if p > job.phasePct {
job.phasePct = clamp01(p)
}
}
}
// 设置任务总时长(视频秒数),ETA 和渲染百分比都靠它
func jobSetTotal(sec float64) {
job.Lock()
defer job.Unlock()
if !job.active {
return
}
job.totalSec = sec
}
// 按目标码率算出来的预计成片大小(byte)
func jobPlanSize(bytes int64) {
job.Lock()
defer job.Unlock()
if !job.active {
return
}
job.planOutBytes = bytes
}
// 把当前 ffmpeg 进程注册进来,暂停/停止才作用得到。
// 如果注册时任务已经处于暂停状态,直接把它挂起(在启动前点了暂停的情况)
func jobAttach(cmd *exec.Cmd) {
job.Lock()
job.proc = cmd
paused := job.paused
job.Unlock()
if paused && cmd != nil && cmd.Process != nil {
_ = suspendProcess(cmd.Process)
}
}
func jobDetach(cmd *exec.Cmd) {
job.Lock()
if job.proc == cmd {
job.proc = nil
}
job.Unlock()
}
// 暂停:挂起当前 ffmpeg 进程;不在 ffmpeg 阶段时靠 jobGate 在下一步生效
func jobPause() error {
job.Lock()
if !job.active {
job.Unlock()
return errors.New("当前没有正在运行的合并任务")
}
if job.paused {
job.Unlock()
return nil
}
job.paused = true
cmd := job.proc
job.Unlock()
if cmd != nil && cmd.Process != nil {
return suspendProcess(cmd.Process)
}
return nil
}
// 恢复
func jobResume() error {
job.Lock()
if !job.active {
job.Unlock()
return errors.New("当前没有正在运行的合并任务")
}
if !job.paused {
job.Unlock()
return nil
}
job.paused = false
cmd := job.proc
job.Unlock()
if cmd != nil && cmd.Process != nil {
return resumeProcess(cmd.Process)
}
return nil
}
// 停止:杀掉当前 ffmpeg,并置位让各层在下一个闸门返回 errJobStopped
func jobStop() error {
job.Lock()
if !job.active {
job.Unlock()
return errors.New("当前没有正在运行的合并任务")
}
job.stopped = true
job.paused = false
cmd := job.proc
job.Unlock()
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
return nil
}
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
// 总进度:已走完的阶段按权重全算,当前阶段按阶段内比例折算
func (j *jobState) percentLocked() float64 {
p := 0.0
for i, w := range jobPhaseWeights {
switch {
case i < j.phaseIdx:
p += w
case i == j.phaseIdx:
p += w * clamp01(j.phasePct)
}
}
return math.Min(p*100, 100)
}
// 预计剩余时间:渲染阶段用「剩余时长 ÷ 倍速」,其它阶段按已用时间线性外推
func (j *jobState) etaLocked(now time.Time) float64 {
if !j.active {
return 0
}
if j.phaseIdx == jobPhaseRender && j.speed > 0 && j.totalSec > j.doneSec {
return (j.totalSec - j.doneSec) / j.speed
}
pct := j.percentLocked()
if pct <= 0.5 {
return 0
}
return now.Sub(j.started).Seconds() * (100 - pct) / pct
}
// 给页面的快照
func jobSnapshot() jobInfo {
job.Lock()
defer job.Unlock()
info := jobInfo{
Active: job.active,
Kind: job.kind,
OutName: job.outName,
PhaseIdx: job.phaseIdx,
Phases: jobPhaseNames,
Paused: job.paused,
Stopping: job.stopped,
Speed: job.speed,
FPS: job.fps,
DoneSec: job.doneSec,
TotalSec: job.totalSec,
ChunkDone: job.chunkDone,
ChunkTotal: job.chunkTotal,
AvatarDone: job.avatarDone,
AvatarTotal: job.avatarTotal,
InputBytes: job.inputBytes,
OutBytes: job.outBytes,
Message: job.message,
Err: job.errText,
DoneAt: job.doneAt,
}
info.PhasePercent = clamp01(job.phasePct) * 100
info.Percent = job.percentLocked()
// 预计成片大小:有实测外推就用实测,否则用按码率估的
info.EstOutBytes, info.EstSource = job.planOutBytes, "bitrate"
if job.liveOutBytes > 0 {
info.EstOutBytes, info.EstSource = job.liveOutBytes, "measured"
}
now := time.Now()
info.ETASec = job.etaLocked(now)
if job.active {
info.ElapsedSec = now.Sub(job.started).Seconds()
} else if job.doneAt > 0 && !job.started.IsZero() {
info.ElapsedSec = time.Unix(job.doneAt, 0).Sub(job.started).Seconds()
}
if len(job.samples) > 0 {
info.Samples = append([]jobSample(nil), job.samples...)
}
// 暂停靠 jobGate 生效:ffmpeg 在跑时直接挂起进程,不在跑时在下一个闸门处停住
info.Pausable = job.active
return info
}
// GET /api/job:当前转换任务的快照,页面轮询它来画进度
func jobHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(jobSnapshot()); err != nil {
lPrintErrf("输出任务进度失败:%v", err)
}
}
// POST /api/job/control:暂停 / 恢复 / 停止当前任务
func jobControlHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
Action string `json:"action"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
return
}
var err error
switch req.Action {
case "pause":
err = jobPause()
case "resume":
err = jobResume()
case "stop":
err = jobStop()
default:
http.Error(w, "不认识的操作:"+req.Action, http.StatusBadRequest)
return
}
if err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}
+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("任务在跑的时候应该可暂停")
}
}
+32 -4
View File
@@ -282,7 +282,12 @@ func placeOutput(tmp, out string) error {
// 跨盘时 os.Rename 会失败(Windows 上尤其明确),退回拷贝:
// 先拷成 .part 再改名,中途失败也不会在成片目录留下像成品的文件
part := out + partSuffix
if err := copyFile(tmp, part); err != nil {
total := fileSizeOrZero(tmp)
if err := copyFileWith(tmp, part, func(done int64) {
if total > 0 {
jobSetPhasePercent(float64(done) / float64(total))
}
}); err != nil {
_ = os.Remove(part)
return fmt.Errorf("拷贝到成片目录 %s 失败:%v", filepath.Dir(out), err)
}
@@ -296,6 +301,11 @@ func placeOutput(tmp, out string) error {
// 拷贝文件,跨盘搬运成片时用
func copyFile(src, dst string) error {
return copyFileWith(src, dst, nil)
}
// 拷贝文件。onBytes 不为空时按累计已拷贝字节回调,跨盘搬运成片时用来报进度
func copyFileWith(src, dst string, onBytes func(int64)) error {
in, err := os.Open(src)
if err != nil {
return err
@@ -305,9 +315,27 @@ func copyFile(src, dst string) error {
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
buf := make([]byte, 1<<20)
var done int64
for {
n, rerr := in.Read(buf)
if n > 0 {
if _, werr := out.Write(buf[:n]); werr != nil {
_ = out.Close()
return werr
}
done += int64(n)
if onBytes != nil {
onBytes(done)
}
}
if rerr == io.EOF {
break
}
if rerr != nil {
_ = out.Close()
return rerr
}
}
return out.Close()
}
+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()