Files
acfunlive-remake/progress.go
T
tamakyi a24425e16d 修第三批 P2:除零、停止/暂停的响应与状态一致性、临时文件、带 BOM 的 ASS
一、弹幕滚动时间不再整数除零
   write() 里拿 (PlayResX+length) 做除数,PlayResX 被配成 0 又收到一条空内容弹幕时
   分母正好是 0,直接 panic 终止整场录制。抽成 scrollMarks 并给分母为 0 的兜底
   (退回半个停留时长,至少让它正常显示出来)。

二、点停止不再要等整个块渲完
   jobAttach 原来只看 paused:停止请求若落在 jobGate 之后、ffmpeg 启动过程中,
   job.proc 还没登记,jobStop 拿不到进程可杀,用户得等整个块渲完(大块要几分钟)。
   现在 attach 时发现任务已请求停止就地把它杀掉,上层拿到 errJobStopped,
   卡片显示的是「已停止」而不是「失败」。

三、暂停没挂住就回滚状态
   jobPause 先把 job.paused 置 true 再调 suspendProcess,挂起失败时状态已经改了:
   界面显示「已暂停」而 ffmpeg 还在跑。失败时回滚(只在 proc 仍是同一个进程时回滚)。
   jobResume 同理:恢复不成放回「已暂停」,免得显示在跑却停着。

四、.ass 还原的临时 jsonl 用完就删
   还原结果写不进录播目录时会退到系统临时目录的固定名文件,原来从不清理,
   每失败一次留一份。新增 danmuSource.tempJsonl 标记 + dropTempDanmu 收尾删除;
   只删标记过的临时文件 —— 正常情况下那个路径是用户录播目录里的同名 jsonl,
   删了就是丢数据。

五、带 BOM 的 ASS 能整份解析
   带 UTF-8 BOM 时首行是 BOM+[Script Info],一个节都进不去,最后只报
   「可能不是弹幕文件」,跟真实原因完全对不上。解析时剥掉 BOM。

六、头像下载期间暂停/停止不用再干等
   单次超时 30 秒 × 3 次重试,最坏 96 秒才响应暂停/停止。单次超时收到 10 秒
   (头像只有几百 KB),并且每次重试前过闸门:暂停在这里等、停止直接放弃,
   不再把剩下的重试跑完;停止也不再打「头像不可用」的告警。

验证:
- gofmt -l 空;go vet ./... 空;go build 与 GOOS=linux go build 通过
- go test ./... 全绿(10.9s);go test -race ./... 全绿(14.5s)
- 新增 p2_fixes_test.go 8 个用例;本轮 6 项加上一批 6 项共 12 项反向验证全部通过
  (每项临时退回源码 → 对应测试确实失败 → 恢复)
- 51897 隔离实例上完整复测:e2e.py 37 项全过、smoke_extra.py 33 项全过
2026-09-26 10:07:30 +08:00

596 lines
16 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.
// 转换任务(合并 / 烧录弹幕)的实时进度与暂停、停止控制。
// 进度只保留一份全局状态:同一时间只允许一个合并任务在跑,串行靠下面的 mergeSlot 保证
// (mergeRun.busy 只拦得住手动合并之间,拦不住自动合并,也拦不住手动与自动互相穿插)。
package main
import (
"encoding/json"
"errors"
"fmt"
"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.RWMutex
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
// 合并任务的串行锁。全局 job 状态只有一份,两个任务同时跑的话后启动的会把前一个的
// 进度和输出名清掉,「停止」也会杀掉别家任务的 ffmpeg,所以必须排队。
// 手动合并用 TryLock(抢不到立刻告诉用户),自动合并用 Lock(后台任务,排队等就行)。
var mergeSlot sync.Mutex
// 尝试占用合并任务位,被占着返回 false。给手动合并用:不能让 HTTP 请求干等着
func tryTakeMergeSlot() bool {
return mergeSlot.TryLock()
}
// 占用合并任务位,被占着就等到前一个跑完。给自动合并用
func takeMergeSlot() {
mergeSlot.Lock()
}
func releaseMergeSlot() {
mergeSlot.Unlock()
}
// 排队跑完一整个合并任务:先独占任务位,跑完后一定释放,被调用方 panic 也不漏。
// 内部出错会被翻成 error 而不是让上层去 recover —— 任务卡片只有走到 jobFinish 才会收尾,
// 漏掉的话进度会永远停在界面上(之前 panic 后卡片一直转就是这么来的)
func runMergeExclusive(f func() error) (err error) {
takeMergeSlot()
defer releaseMergeSlot()
defer func() {
if p := recover(); p != nil {
err = fmt.Errorf("合并任务内部出错:%v", p)
lPrintErrf("合并任务内部出错,已兜住:%v", p)
}
}()
return f()
}
// 开始一个新任务。kind 是任务类型,totalSec 是视频总时长(拿不到填 0)。
// 已经有任务在跑时返回 false 并且什么都不动:调用方应该先拿到 mergeSlot,
// 这里再做一次判断是为了让误用(忘记排队)只会失败而不会互相覆盖
func jobStart(kind, outName string, inputBytes int64, totalSec float64) bool {
job.Lock()
defer job.Unlock()
if job.active {
return false
}
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
return true
}
// 当前是否有任务在跑。给界面提示和「现在能不能接新任务」的判断用
func jobActive() bool {
job.RLock()
defer job.RUnlock()
return job.active
}
// 当前任务的名字(没任务时为空),拒绝新任务时用它告诉用户在忙什么
func jobBusyName() string {
job.RLock()
defer job.RUnlock()
if !job.active {
return ""
}
return job.outName
}
// 任务结束。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 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 进程注册进来,暂停/停止才作用得到。
// 如果注册时任务已经处于暂停状态,直接把它挂起(在启动前点了暂停的情况);
// 如果任务已经被要求停止,就地杀掉 —— jobGate 已经过去了,不补这一刀的话
// 用户点了停止还得等整个块渲完(大块的渲染可能要几分钟)
func jobAttach(cmd *exec.Cmd) {
job.Lock()
job.proc = cmd
paused, stopped := job.paused, job.stopped
job.Unlock()
if cmd == nil || cmd.Process == nil {
return
}
if stopped {
_ = cmd.Process.Kill()
return
}
if paused {
_ = 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 {
if err := suspendProcess(cmd.Process); err != nil {
// 没挂住就别留着「已暂停」:界面会显示暂停了、ffmpeg 其实还在跑,
// 用户以为停住了却在烧 CPU。只在进程还是同一个时回滚,免得踩到已经换过的进程
job.Lock()
if job.proc == cmd {
job.paused = false
}
job.Unlock()
return err
}
}
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 {
if err := resumeProcess(cmd.Process); err != nil {
// 没恢复成就把状态放回「已暂停」:否则界面显示在跑、ffmpeg 其实还停着
job.Lock()
if job.proc == cmd {
job.paused = true
}
job.Unlock()
return err
}
}
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}`))
}