diff --git a/ass2jsonl.go b/ass2jsonl.go index 6b0d041..f7365cb 100644 --- a/ass2jsonl.go +++ b/ass2jsonl.go @@ -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 diff --git a/danmu.go b/danmu.go index dc23215..76aa6ae 100644 --- a/danmu.go +++ b/danmu.go @@ -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), diff --git a/danmuburn.go b/danmuburn.go index 7fa7d8d..960c1c8 100644 --- a/danmuburn.go +++ b/danmuburn.go @@ -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) diff --git a/manualmerge.go b/manualmerge.go index 2c445de..01340ce 100644 --- a/manualmerge.go +++ b/manualmerge.go @@ -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)) diff --git a/p2_fixes_test.go b/p2_fixes_test.go new file mode 100644 index 0000000..86b182c --- /dev/null +++ b/p2_fixes_test.go @@ -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) + } +} diff --git a/progress.go b/progress.go index bd86862..b643cc4 100644 --- a/progress.go +++ b/progress.go @@ -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 }