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), 头像跟着文字同步走,没有回归。
209 lines
6.7 KiB
Go
209 lines
6.7 KiB
Go
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)
|
||
}
|
||
}
|