Files
tamakyi 3e16e78003 .ass 时间基准和视频对不上时按第一条弹幕自动对齐,并去掉重复的还原日志
1. 整场弹幕都被判「超出视频时长」,合并直接取消

   上游原版录出来的 .ass 时间不是相对录制起点,而是相对弹幕客户端连上房间的时刻。
   程序先开着、直播中途才开始录的场次,这份 .ass 的零点会比视频起点早一大截
   (用户实测那份早了 7849.92 秒:955 条弹幕、跨度 5436.7 秒,视频只有 5456.9 秒)。
   一律按「视频时间 = 弹幕时间 + 偏移」算的话,这 955 条全都落在 5456.9 秒之外,
   于是「有 955 条弹幕超出了录播视频的时长,已跳过」→「弹幕文件里没有弹幕,取消手动合并」,
   一分半的录播一条弹幕都合不出来。

   这类偏移是能救的:跨度(末 - 首)5436.7 秒装得进 5456.9 秒,整体前移 7849.92 秒之后
   覆盖 0 ~ 5436.7 秒、尾部正好空 20.2 秒 —— 和正常录播的尾部空隙(实测 6~20 秒)吻合,
   是时间基准偏移,不是文件配错。

   新增 fitDanmuToVideo,在探到视频时长之后、裁剪之前介入,触发条件收得很紧:
   只有「按现偏移一条弹幕都用不上」**且**「弹幕跨度 < 视频时长」才整体平移,把第一条
   对齐到视频起点,日志写明平移量、对齐后的覆盖范围和尾部空隙,并提示整体偏早或偏晚
   可以在「偏移」里微调。其余情况完全保持原行为:只是一部分超出 → 照旧只丢尾部;
   跨度本身就装不下 → 一条不留。这样不可能影响任何现在能正常合并的场次。

2. 「已从 xxx.ass 还原出 N 条弹幕」会打印两遍

   页面的「检查」和真正合并会各解析一遍 .ass,而这条日志写在被复用的 parseAssFile 里,
   于是同一句话出现两次,第二遍后面还跟着「已把 xxx.ass 还原成 xxx.jsonl」,看不出谁是谁。
   把这两条日志从 parseAssFile 里摘出去(它改成返回坏行数 bad,由调用方决定要不要告警),
   交给真正落盘那次的调用方 prepareDanmuSource(path, true) 打印,现在还会写明写成了
   哪个文件;坏行警告统一走新增的 logAssBadLines,不再重复。

   顺手给「最后一条弹幕超出视频总长」那条警告补了指引,说明合成时会自动对齐。

3. 测试里后台任务漏到用例结束之后

   safeGo 原来直接把 goroutine 扔出去,用例结束它还在读全局配置,下一个用例开头一改配置,
   -race 就判成数据竞争(而且报在后面的用例上,很难看出真凶)。把「怎么起后台任务」抽成
   var runBackground,生产行为不变,测试里换成可等待的版本,清理时等它收工。

   注意:这个竞争单独 -run 一个用例复现不出来(-race -count=3 也过),必须整包跑
   go test -race . 才会在后续用例上报 race detected。

验证

- gofmt 无输出 / go vet 通过 / go test(182 个,18.5s)全过 / go test -race 整包跑 20 轮
  (13 轮顺序 + 6 轮 -shuffle=on)全过 / staticcheck 无输出 / GOOS=linux go build 通过。
  另:有一轮 -race 曾报过 FAIL、随后 20 轮都没能复现,怀疑是 TestDownloadAvatarCapsResponseBody
  自带的抖动(它的断言依赖「服务端写不完 10MB」,受内核缓冲大小影响),与本批改动无关,
  该文件本批也没动过;待单独确认。
- 反向验证从 32 项扩到 37 项全过(新增:自动对齐 / 只丢尾部不平移 / 跨度装不进不强平移 /
  还原日志只打一次 / 后台任务可等待);脚本新增单用例附加参数 "args" 与不带 -run 的整包用例。
- 用用户那份真实素材(955 条,7849.92 ~ 13286.59 秒,视频 5456.883 秒)端到端跑通:
  不填偏移 + 5460 秒视频 → 日志「弹幕时间基准和视频对不上:955 条弹幕全部落在视频时长
  (5460.0 秒)之外,最早那条在 7849.9 秒;已按「第一条弹幕对齐视频起点」整体前移
  7849.9 秒(偏移 0.0 → -7849.9)」「对齐后弹幕覆盖 0.0 ~ 5436.7 秒,视频总长 5460.0 秒
  (尾部空 23.3 秒)」,分 5 块渲染,exit=0,成片 5462.31 秒 / 23.3 MB。
  再按 jsonl + 偏移反推「哪些时刻该有弹幕」,随机取 40 个应有时刻 + 20 个任意弹幕窗口都
  不覆盖的空屏时刻,抽帧数白像素(白字黑底):60/60 全对,跨块边界(1116/2435/3393/4408)
  与最尾部 t=5435.7 都对得上。
- 60 秒小片新旧对照(真实 9 条弹幕、原始时间戳 7849.92 ~ 7908.7):旧版「有 9 条弹幕超出了
  录播视频的时长(60.1 秒),已跳过」+「jsonl 里没有弹幕」、无成片;新版自动前移 7849.9 秒、
  9 条全画上、成片 34 MB。抽帧看轨迹仍是匀速 10 秒穿屏(danmuShowSeconds),
  头像跟着文字同步走,没有回归。
2026-09-26 17:51:43 +08:00

209 lines
6.7 KiB
Go
Raw Permalink 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.
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)
}
}