修第三批 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 项全过
This commit is contained in:
@@ -157,6 +157,9 @@ func parseAssFile(path string) (danmuMeta, []danmuRecord, error) {
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimRight(scanner.Text(), "\r")
|
||||
// 有些编辑器/工具保存的 ASS 头上有 UTF-8 BOM,第一个 "[" 会被它挡住:
|
||||
// 整份文件进不了任何一节,最后只会报一句「可能不是弹幕文件」,跟真实原因完全对不上
|
||||
line = strings.TrimPrefix(line, "\ufeff")
|
||||
t := strings.TrimSpace(line)
|
||||
if t == "" || strings.HasPrefix(t, ";") {
|
||||
continue
|
||||
|
||||
@@ -146,6 +146,18 @@ func textWidth(text string, fontSize int) int {
|
||||
return w
|
||||
}
|
||||
|
||||
// 弹幕滚动的两个关键时刻:完全进入(emerge)和完全离开(left)。
|
||||
// resX 是画面宽度、length 是弹幕宽度,两者相加是它要走的整段行程。
|
||||
// 配置异常(PlayResX 为 0)碰上空内容时这段行程会是 0,直接拿它做除数是整数除零 panic
|
||||
// (会终止整场录制),这种情况退回「半个停留时长」,至少让它正常显示出来。
|
||||
func scrollMarks(rel int64, resX, length int, show int64) (emerge, left int64) {
|
||||
total := int64(resX + length)
|
||||
if total <= 0 {
|
||||
return rel + show/2, rel + show
|
||||
}
|
||||
return rel + int64(length)*show/total, rel + int64(resX)*show/total
|
||||
}
|
||||
|
||||
// 在弹道数组里寻找空闲弹道,返回弹道行号,没有空闲返回 -1,公式与 acfundanmu 保持一致
|
||||
func placeLane(last []laneState, dt laneState, leftTime int64) int {
|
||||
for i, t := range last {
|
||||
@@ -242,12 +254,12 @@ func (w *danmuWriter) write(c *acfundanmu.Comment) {
|
||||
|
||||
// 原格式 ASS,碰撞避让和坐标公式与 acfundanmu 完全一致
|
||||
length := utf8.RuneCountInString(c.Content) * s.FontSize
|
||||
emergeO, leftO := scrollMarks(rel, s.PlayResX, length, danmuShowDur)
|
||||
dtO := laneState{
|
||||
appear: rel,
|
||||
emerge: rel + (int64(length)*danmuShowDur)/int64(s.PlayResX+length),
|
||||
emerge: emergeO,
|
||||
disappear: rel + danmuShowDur,
|
||||
}
|
||||
leftO := rel + (int64(s.PlayResX)*danmuShowDur)/int64(s.PlayResX+length)
|
||||
if i := placeLane(w.lastO, dtO, leftO); i >= 0 {
|
||||
if _, err := fmt.Fprintf(w.ass, dialogueTmpl,
|
||||
assTime(dtO.appear), assTime(dtO.disappear),
|
||||
@@ -262,12 +274,12 @@ func (w *danmuWriter) write(c *acfundanmu.Comment) {
|
||||
width := textWidth(text, s.FontSize)
|
||||
avW, avGap := avatarSize(s.FontSize), avatarGap(s.FontSize)
|
||||
total := avW + avGap + width // 头像、间隔和文字的总宽度
|
||||
emergeN, leftN := scrollMarks(rel, s.PlayResX, total, danmuShowDur)
|
||||
dtN := laneState{
|
||||
appear: rel,
|
||||
emerge: rel + (int64(total)*danmuShowDur)/int64(s.PlayResX+total),
|
||||
emerge: emergeN,
|
||||
disappear: rel + danmuShowDur,
|
||||
}
|
||||
leftN := rel + (int64(s.PlayResX)*danmuShowDur)/int64(s.PlayResX+total)
|
||||
if i := placeLane(w.lastN, dtN, leftN); i >= 0 {
|
||||
if _, err := fmt.Fprintf(w.nick, dialogueNickTmpl,
|
||||
assTime(dtN.appear), assTime(dtN.disappear),
|
||||
|
||||
+17
-3
@@ -169,7 +169,10 @@ func runDanmuburn(danmuFile, videoFile, outFile string, offset float64) {
|
||||
lPrintErr(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// 还原结果写不进原目录时会落在系统临时目录里,收工前删掉。
|
||||
// 这里到处都是 os.Exit,defer 不会被执行,所以显式调用
|
||||
if err := burnDanmu(src.jsonlPath, videoFile, outFile, offset); err != nil {
|
||||
dropTempDanmu(src)
|
||||
if err == errNoDanmu {
|
||||
lPrintWarn(err.Error())
|
||||
os.Exit(0)
|
||||
@@ -177,6 +180,7 @@ func runDanmuburn(danmuFile, videoFile, outFile string, offset float64) {
|
||||
lPrintErr(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
dropTempDanmu(src)
|
||||
}
|
||||
|
||||
// 合成一个 jsonl(和可选的视频)为带头像的弹幕层
|
||||
@@ -1104,10 +1108,18 @@ func ensureAvatar(uid int64, url, cacheDir string, legacyDirs ...string) string
|
||||
}
|
||||
|
||||
err := runThrice(func() error {
|
||||
// 每次重试前过闸门:暂停就在这里等着(不会把剩下的重试继续跑完),
|
||||
// 停止就直接放弃 —— 否则点了暂停/停止还得等这个头像把三次重试耗完
|
||||
if err := jobGate(); err != nil {
|
||||
return noRetry(err)
|
||||
}
|
||||
return downloadAvatar(uid, url, path)
|
||||
})
|
||||
if err != nil {
|
||||
lPrintWarnf("uid %d 的头像不可用,该用户的弹幕只烧录文字不带头像:%v", uid, err)
|
||||
// 停止不算故障,不用报「头像不可用」
|
||||
if !errors.Is(err, errJobStopped) {
|
||||
lPrintWarnf("uid %d 的头像不可用,该用户的弹幕只烧录文字不带头像:%v", uid, err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return path
|
||||
@@ -1121,8 +1133,10 @@ const (
|
||||
avatarMaxPixels = 2048 * 2048
|
||||
)
|
||||
|
||||
// 复用连接:一场直播要下几百个头像,每个都新建 client 就是几百次握手
|
||||
var avatarClient = &http.Client{Timeout: 30 * time.Second}
|
||||
// 复用连接:一场直播要下几百个头像,每个都新建 client 就是几百次握手。
|
||||
// 超时给 10 秒:头像只有几百 KB,超过就说明这个 CDN 节点不通,
|
||||
// 早点超时能早点重试/过闸门,用户点暂停时也不用干等半分钟
|
||||
var avatarClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
func downloadAvatar(uid int64, url, path string) error {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
|
||||
@@ -251,6 +251,9 @@ type danmuSource struct {
|
||||
last float64
|
||||
hasAvatar bool
|
||||
source string // 来源说明
|
||||
// 从 .ass 还原出来的结果写不进原目录、退到系统临时目录时置位:
|
||||
// 这种文件调用方用完必须删掉,别在临时目录里越攒越多
|
||||
tempJsonl bool
|
||||
}
|
||||
|
||||
func prepareDanmuSource(path string, write bool) (danmuSource, error) {
|
||||
@@ -294,12 +297,21 @@ func prepareDanmuSource(path string, write bool) (danmuSource, error) {
|
||||
}
|
||||
lPrintWarnf("无法把还原结果写入 %s(%v),改用临时文件 %s", jsonlPath, err, tmp)
|
||||
src.jsonlPath = tmp
|
||||
src.tempJsonl = true
|
||||
return src, nil
|
||||
}
|
||||
lPrintf("已把 %s 还原成 %s", filepath.Base(path), filepath.Base(jsonlPath))
|
||||
return src, nil
|
||||
}
|
||||
|
||||
// .ass 还原结果退到系统临时目录时,用完就删。只删标记过的临时文件:
|
||||
// 正常情况 jsonlPath 是用户录播目录里的同名 jsonl,删了就是丢数据
|
||||
func dropTempDanmu(src danmuSource) {
|
||||
if src.tempJsonl {
|
||||
_ = os.Remove(src.jsonlPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 输出文件名只能是文件名本身,且固定为 mp4
|
||||
func sanitizeOutName(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
@@ -479,6 +491,8 @@ func runManualMerge(plan mergePlan) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 还原结果写不进原目录时用的是系统临时目录里的文件,合并完就删掉
|
||||
defer dropTempDanmu(src)
|
||||
|
||||
// 每一段的录制开始时刻取自文件名,用来算偏移和拼接后的时间轴
|
||||
segs := make([]recordSegment, 0, len(plan.videos))
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/orzogc/acfundanmu"
|
||||
)
|
||||
|
||||
// 直接摆布全局任务状态:测试里只碰这几个标志位,用完恢复
|
||||
func withJobFlags(t *testing.T, active, paused, stopped bool) {
|
||||
t.Helper()
|
||||
job.Lock()
|
||||
prevActive, prevPaused, prevStopped, prevProc := job.active, job.paused, job.stopped, job.proc
|
||||
job.active, job.paused, job.stopped = active, paused, stopped
|
||||
job.Unlock()
|
||||
t.Cleanup(func() {
|
||||
job.Lock()
|
||||
job.active, job.paused, job.stopped, job.proc = prevActive, prevPaused, prevStopped, prevProc
|
||||
job.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
// resX + length 为 0(PlayResX 配置异常 + 空内容)时不能整数除零
|
||||
func TestScrollMarksFallsBackWhenTotalIsZero(t *testing.T) {
|
||||
emerge, left := scrollMarks(1000, 0, 0, danmuShowDur)
|
||||
if emerge != 1000+danmuShowDur/2 {
|
||||
t.Fatalf("行程为 0 时 emerge 应当退到半个停留时长,实际 %d", emerge-1000)
|
||||
}
|
||||
if left != 1000+danmuShowDur {
|
||||
t.Fatalf("行程为 0 时 left 应当退到一个停留时长,实际 %d", left-1000)
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:配置里 PlayResX 为 0 又收到一条空弹幕时,写入器不能 panic
|
||||
func TestDanmuWriterSurvivesZeroPlayRes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := acfundanmu.SubConfig{Title: "测试", PlayResX: 0, PlayResY: 0,
|
||||
FontSize: 40, StartTime: 1700000000000000000}
|
||||
w := newDanmuWriter("liveid", "stream", cfg, filepath.Join(dir, "zero.ass"))
|
||||
|
||||
c := &acfundanmu.Comment{Content: ""} // 空内容 → 宽度 0,和 PlayResX=0 一起把分母变成 0
|
||||
c.SendTime = (cfg.StartTime + int64(time.Second)) / 1e6
|
||||
c.UserID = 1
|
||||
c.Nickname = "甲"
|
||||
w.write(c) // 原来这里整数除零 panic,会终止整场录制
|
||||
if err := w.close(); err != nil {
|
||||
t.Fatalf("关文件不该报错:%v", err)
|
||||
}
|
||||
if w.failed() {
|
||||
t.Fatalf("写盘不该出错:%v", w.err)
|
||||
}
|
||||
|
||||
// 这条弹幕仍要正常出现在 jsonl 里
|
||||
b, err := os.ReadFile(filepath.Join(dir, "zero.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lines := strings.Split(strings.TrimSpace(string(b)), "\n"); len(lines) != 2 {
|
||||
t.Fatalf("应当写出 1 条弹幕记录(加首行元数据共 2 行),实际 %d 行", len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
// 任务已请求停止时,jobAttach 要就地结束刚启动的进程,
|
||||
// 否则用户点了停止还得等整个块渲完
|
||||
func TestJobAttachKillsProcessWhenStopped(t *testing.T) {
|
||||
var cmd *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.Command("cmd", "/c", "ping", "-n", "30", "127.0.0.1")
|
||||
} else {
|
||||
cmd = exec.Command("sleep", "30")
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Skipf("起不了测试子进程,跳过:%v", err)
|
||||
}
|
||||
defer func() { _ = cmd.Process.Kill() }()
|
||||
|
||||
withJobFlags(t, true, false, true) // 任务已经处于「已请求停止」
|
||||
jobAttach(cmd)
|
||||
defer jobDetach(cmd)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("任务已请求停止时,jobAttach 应当就地结束刚启动的进程,不能等它自己跑完")
|
||||
}
|
||||
}
|
||||
|
||||
// 挂起失败(进程已经不在)时不能留着「已暂停」的状态
|
||||
func TestJobPauseRollsBackStateWhenSuspendFails(t *testing.T) {
|
||||
withJobFlags(t, true, false, false)
|
||||
fake := &exec.Cmd{Process: &os.Process{Pid: 999999}} // 不存在的进程号,挂起必然失败
|
||||
jobAttach(fake)
|
||||
defer jobDetach(fake)
|
||||
|
||||
if err := jobPause(); err == nil {
|
||||
t.Fatal("挂起一个不存在的进程应当报错")
|
||||
}
|
||||
job.Lock()
|
||||
paused := job.paused
|
||||
job.Unlock()
|
||||
if paused {
|
||||
t.Fatal("挂起失败后不该留着「已暂停」:界面会显示已暂停,而 ffmpeg 其实还在跑")
|
||||
}
|
||||
}
|
||||
|
||||
// 只有标记成临时的 jsonl 才能删,正常同名 jsonl 一个字节都不能动
|
||||
func TestDropTempDanmuOnlyRemovesTempFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
keep := filepath.Join(dir, "keep.jsonl")
|
||||
if err := os.WriteFile(keep, []byte("{}\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dropTempDanmu(danmuSource{jsonlPath: keep})
|
||||
if _, err := os.Stat(keep); err != nil {
|
||||
t.Fatalf("正常的同名 jsonl 不能被删掉:%v", err)
|
||||
}
|
||||
|
||||
tmp := filepath.Join(dir, "tmp.jsonl")
|
||||
if err := os.WriteFile(tmp, []byte("{}\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dropTempDanmu(danmuSource{jsonlPath: tmp, tempJsonl: true})
|
||||
if _, err := os.Stat(tmp); err == nil {
|
||||
t.Fatal("标记为临时的还原结果应当在收尾时删掉")
|
||||
}
|
||||
}
|
||||
|
||||
// 还原结果写不进原目录时退回系统临时目录,并且要打上临时标记
|
||||
func TestPrepareDanmuSourceMarksTempFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
assPath := filepath.Join(dir, "bom.ass")
|
||||
if err := os.WriteFile(assPath, []byte(plainAssFixture), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 同名 jsonl 占成只读:写不进去,只能退到临时目录
|
||||
jsonlPath := filepath.Join(dir, "bom.jsonl")
|
||||
if err := os.WriteFile(jsonlPath, []byte("{}\n"), 0444); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(jsonlPath, 0644) })
|
||||
|
||||
src, err := prepareDanmuSource(assPath, true)
|
||||
if err != nil {
|
||||
t.Skipf("这台机器上没能造出「写不进去」的条件,跳过:%v", err)
|
||||
}
|
||||
if !src.tempJsonl {
|
||||
t.Skip("写进原目录成功了(文件系统不认只读属性),跳过")
|
||||
}
|
||||
if src.jsonlPath == jsonlPath {
|
||||
t.Fatal("退回临时文件时 jsonlPath 不该还指着原路径")
|
||||
}
|
||||
if _, err := os.Stat(src.jsonlPath); err != nil {
|
||||
t.Fatalf("临时文件应当已经写好:%v", err)
|
||||
}
|
||||
dropTempDanmu(src)
|
||||
if _, err := os.Stat(src.jsonlPath); err == nil {
|
||||
t.Fatal("临时文件应当在收尾时被删掉")
|
||||
}
|
||||
}
|
||||
|
||||
// 带 UTF-8 BOM 的 ASS 也要能整份解析出来
|
||||
func TestParseAssFileAcceptsBOM(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "bom.ass")
|
||||
if err := os.WriteFile(p, []byte("\ufeff"+plainAssFixture), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
meta, records, err := parseAssFile(p)
|
||||
if err != nil {
|
||||
t.Fatalf("带 BOM 的 ASS 应当能正常解析,实际报错:%v", err)
|
||||
}
|
||||
if meta.PlayResX != 1920 || meta.PlayResY != 1080 {
|
||||
t.Fatalf("分辨率应当解析出来,实际 %dx%d", meta.PlayResX, meta.PlayResY)
|
||||
}
|
||||
if meta.FontSize != 48 {
|
||||
t.Fatalf("字号应当解析出来,实际 %d", meta.FontSize)
|
||||
}
|
||||
if len(records) != 2 {
|
||||
t.Fatalf("应当读到 2 条弹幕(Comment 那行不算),实际 %d", len(records))
|
||||
}
|
||||
}
|
||||
|
||||
// 停止后下载头像不该把三次重试耗完(原来最坏要等 96 秒才响应停止)
|
||||
func TestEnsureAvatarGivesUpAtOnceWhenStopped(t *testing.T) {
|
||||
withJobFlags(t, true, false, true)
|
||||
dir := t.TempDir()
|
||||
|
||||
t0 := time.Now()
|
||||
got := ensureAvatar(424242, "http://127.0.0.1:1/none.png", dir)
|
||||
elapsed := time.Since(t0)
|
||||
|
||||
if got != "" {
|
||||
t.Fatalf("停止状态下不该返回头像路径,实际 %q", got)
|
||||
}
|
||||
if elapsed > 3*time.Second {
|
||||
t.Fatalf("停止后应当立刻放弃这个头像,实际等了 %v", elapsed)
|
||||
}
|
||||
}
|
||||
+31
-5
@@ -367,13 +367,22 @@ func jobPlanSize(bytes int64) {
|
||||
}
|
||||
|
||||
// 把当前 ffmpeg 进程注册进来,暂停/停止才作用得到。
|
||||
// 如果注册时任务已经处于暂停状态,直接把它挂起(在启动前点了暂停的情况)
|
||||
// 如果注册时任务已经处于暂停状态,直接把它挂起(在启动前点了暂停的情况);
|
||||
// 如果任务已经被要求停止,就地杀掉 —— jobGate 已经过去了,不补这一刀的话
|
||||
// 用户点了停止还得等整个块渲完(大块的渲染可能要几分钟)
|
||||
func jobAttach(cmd *exec.Cmd) {
|
||||
job.Lock()
|
||||
job.proc = cmd
|
||||
paused := job.paused
|
||||
paused, stopped := job.paused, job.stopped
|
||||
job.Unlock()
|
||||
if paused && cmd != nil && cmd.Process != nil {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
if stopped {
|
||||
_ = cmd.Process.Kill()
|
||||
return
|
||||
}
|
||||
if paused {
|
||||
_ = suspendProcess(cmd.Process)
|
||||
}
|
||||
}
|
||||
@@ -401,7 +410,16 @@ func jobPause() error {
|
||||
cmd := job.proc
|
||||
job.Unlock()
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
return suspendProcess(cmd.Process)
|
||||
if err := suspendProcess(cmd.Process); err != nil {
|
||||
// 没挂住就别留着「已暂停」:界面会显示暂停了、ffmpeg 其实还在跑,
|
||||
// 用户以为停住了却在烧 CPU。只在进程还是同一个时回滚,免得踩到已经换过的进程
|
||||
job.Lock()
|
||||
if job.proc == cmd {
|
||||
job.paused = false
|
||||
}
|
||||
job.Unlock()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -421,7 +439,15 @@ func jobResume() error {
|
||||
cmd := job.proc
|
||||
job.Unlock()
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
return resumeProcess(cmd.Process)
|
||||
if err := resumeProcess(cmd.Process); err != nil {
|
||||
// 没恢复成就把状态放回「已暂停」:否则界面显示在跑、ffmpeg 其实还停着
|
||||
job.Lock()
|
||||
if job.proc == cmd {
|
||||
job.paused = true
|
||||
}
|
||||
job.Unlock()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user