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), 头像跟着文字同步走,没有回归。
220 lines
8.0 KiB
Go
220 lines
8.0 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// 造 n 条弹幕:从 start 秒开始,每条间隔 step
|
|
func mkDanmuForFit(start, step float64, n int) []danmuRecord {
|
|
out := make([]danmuRecord, 0, n)
|
|
for i := 0; i < n; i++ {
|
|
out = append(out, danmuRecord{
|
|
Time: start + float64(i)*step, UID: int64(100 + i), Name: "甲",
|
|
Content: fmt.Sprintf("第 %d 条", i), Width: 100,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 正常场次:时间轴一点都不该动
|
|
func TestFitDanmuToVideoLeavesNormalAlone(t *testing.T) {
|
|
records := mkDanmuForFit(1, 1, 10) // 1 ~ 10 秒
|
|
got, off := fitDanmuToVideo(records, 0, 60)
|
|
if len(got) != 10 || off != 0 {
|
|
t.Fatalf("正常场次不该动:留 %d 条、偏移 %v", len(got), off)
|
|
}
|
|
}
|
|
|
|
// 弹幕确实录得比视频久(视频中途断过):只丢尾部超出的,时间轴不动
|
|
func TestFitDanmuToVideoDropsTailOnly(t *testing.T) {
|
|
records := mkDanmuForFit(1, 5, 20) // 1 ~ 96 秒,视频只有 50 秒
|
|
got, off := fitDanmuToVideo(records, 0, 50)
|
|
if off != 0 {
|
|
t.Fatalf("只该丢尾部,偏移应保持 0,实际 %v", off)
|
|
}
|
|
if len(got) == 0 || len(got) == len(records) {
|
|
t.Fatalf("应该留一部分、丢一部分,实际留 %d 条", len(got))
|
|
}
|
|
if last := got[len(got)-1].Time; last >= 50 {
|
|
t.Fatalf("留下的最后一条应在视频内,实际 %v", last)
|
|
}
|
|
}
|
|
|
|
// 时间基准整体偏移的 .ass(时间相对程序启动、不是录制开始):按第一条弹幕对齐视频起点
|
|
func TestFitDanmuToVideoAlignsShiftedTimeBase(t *testing.T) {
|
|
// 现实素材的形状:7849.92 ~ 13286.59 秒 / 视频 5456.883 秒
|
|
records := mkDanmuForFit(7849.92, 5.7, 955)
|
|
got, off := fitDanmuToVideo(records, 0, 5456.883)
|
|
if off != -7849.92 {
|
|
t.Fatalf("偏移应为 -7849.92,实际 %v", off)
|
|
}
|
|
if len(got) != len(records) {
|
|
t.Fatalf("跨度装得进视频,不该丢弹幕,实际留 %d / %d 条", len(got), len(records))
|
|
}
|
|
if first := got[0].Time + off; first != 0 {
|
|
t.Fatalf("第一条应落在视频起点,实际 %v", first)
|
|
}
|
|
if end := got[len(got)-1].Time + off; end >= 5456.883 {
|
|
t.Fatalf("最后一条应在视频内,实际 %v", end)
|
|
}
|
|
}
|
|
|
|
// 边界:跨度刚好比视频短一点点,对齐后一条都不该丢
|
|
func TestFitDanmuToVideoAlignJustFits(t *testing.T) {
|
|
records := mkDanmuForFit(1000, 1, 1000) // 1000 ~ 1999 秒,跨度 999 秒
|
|
got, off := fitDanmuToVideo(records, 0, 1000)
|
|
if off != -1000 {
|
|
t.Fatalf("偏移应为 -1000,实际 %v", off)
|
|
}
|
|
if len(got) != len(records) {
|
|
t.Fatalf("跨度装得进视频,不该丢弹幕,实际留 %d / %d 条", len(got), len(records))
|
|
}
|
|
if end := got[len(got)-1].Time + off; end >= 1000 {
|
|
t.Fatalf("最后一条应在视频内,实际 %v", end)
|
|
}
|
|
}
|
|
|
|
// 弹幕跨度本身就装不进视频:平移救不回来,只能按原行为一条不留
|
|
func TestFitDanmuToVideoGivesUpWhenSpanTooLong(t *testing.T) {
|
|
records := mkDanmuForFit(7849.92, 10, 100) // 跨度 990 秒 > 视频 500 秒
|
|
got, off := fitDanmuToVideo(records, 0, 500)
|
|
if len(got) != 0 {
|
|
t.Fatalf("跨度装不下时应一条不留,实际 %d 条", len(got))
|
|
}
|
|
if off != 0 {
|
|
t.Fatalf("放弃时不该改偏移,实际 %v", off)
|
|
}
|
|
}
|
|
|
|
// 偏移把弹幕整体推到视频之前时保持原行为(这些条目不算「超出时长」,由渲染那边处理)
|
|
func TestFitDanmuToVideoKeepsAllBeforeVideoBehaviour(t *testing.T) {
|
|
records := mkDanmuForFit(100, 1, 10)
|
|
got, off := fitDanmuToVideo(records, -1000, 500)
|
|
if len(got) != len(records) || off != -1000 {
|
|
t.Fatalf("整体在视频之前时不该动:留 %d 条、偏移 %v", len(got), off)
|
|
}
|
|
}
|
|
|
|
// 用户实测的那种场次:.ass 时间基准比视频晚一大截,合并要能成功并且真的画上弹幕
|
|
func TestManualMergeFromShiftedAssEndToEnd(t *testing.T) {
|
|
dir := setupMergeTest(t)
|
|
ffmpeg := getFFmpeg()
|
|
if ffmpeg == "" {
|
|
t.Skip("没有 ffmpeg")
|
|
}
|
|
|
|
name := "2026-09-25 02-00-00 测试主播 时间基准偏移"
|
|
video := filepath.Join(recordSaveDir(), name+".mp4")
|
|
cmd := exec.Command(ffmpeg, "-v", "error", "-y",
|
|
"-f", "lavfi", "-i", "color=c=black:size=640x360:rate=25", "-t", "8",
|
|
"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", video)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("生成测试视频失败:%v\n%s", err, out)
|
|
}
|
|
|
|
// 弹幕时间整体比录制起点晚 2 小时(模拟「程序先开着、直播中途才开始录」)
|
|
ass := fmt.Sprintf(`[Script Info]
|
|
Title: %s
|
|
ScriptType: v4.00+
|
|
Collisions: Normal
|
|
PlayResX: 640
|
|
PlayResY: 360
|
|
|
|
[V4+ Styles]
|
|
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
|
Style: Danmu,Microsoft YaHei,40,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,20,20,2,0
|
|
|
|
[Events]
|
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|
Dialogue: 0,2:00:01.00,2:00:11.00,Danmu,甲(101),20,20,2,,{\move(400,40,-40,40)}第一条弹幕
|
|
Dialogue: 0,2:00:03.00,2:00:13.00,Danmu,乙(202),20,20,2,,{\move(400,80,-40,80)}第二条弹幕
|
|
Dialogue: 0,2:00:05.00,2:00:15.00,Danmu,甲(101),20,20,2,,{\move(400,120,-40,120)}第三条弹幕
|
|
`, name)
|
|
assPath := writeTestFile(t, filepath.Join(recordSaveDir(), name+".ass"), ass)
|
|
|
|
if _, err := prepareDanmuSource(assPath, true); err != nil {
|
|
t.Fatalf("还原弹幕失败:%v", err)
|
|
}
|
|
plan, err := buildMergePlan(mergeRequest{Videos: []string{video}, Danmu: assPath})
|
|
if err != nil {
|
|
t.Fatalf("检查失败:%v", err)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
oldOut := logger.Writer()
|
|
logger.SetOutput(&buf)
|
|
err = execManualMerge(plan)
|
|
logger.SetOutput(oldOut)
|
|
if err != nil {
|
|
t.Fatalf("时间基准偏移的场次应当能合出来,实际失败:%v\n日志:\n%s", err, buf.String())
|
|
}
|
|
log := buf.String()
|
|
if !strings.Contains(log, "按「第一条弹幕对齐视频起点」") {
|
|
t.Fatalf("日志里应写明自动对齐:\n%s", log)
|
|
}
|
|
if !strings.Contains(log, "对齐后弹幕覆盖") {
|
|
t.Fatalf("日志里应写明对齐后的覆盖范围:\n%s", log)
|
|
}
|
|
|
|
if _, err := os.Stat(plan.out); err != nil {
|
|
t.Fatalf("没有生成成片:%v", err)
|
|
}
|
|
|
|
// 成片里确实画了弹幕:同一时刻抽帧,有字的帧 PNG 一定比纯黑源片大
|
|
shot := func(src, tag string) int {
|
|
out := filepath.Join(dir, "shot_"+tag+".png")
|
|
c := exec.Command(ffmpeg, "-v", "error", "-y", "-ss", "3", "-i", src,
|
|
"-frames:v", "1", "-update", "1", out)
|
|
if o, err := c.CombinedOutput(); err != nil {
|
|
t.Fatalf("抽帧失败:%v\n%s", err, o)
|
|
}
|
|
fi, err := os.Stat(out)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return int(fi.Size())
|
|
}
|
|
srcSize, outSize := shot(video, "src"), shot(plan.out, "out")
|
|
if outSize <= srcSize {
|
|
t.Fatalf("成片的帧没有比纯黑源片大,弹幕很可能没画上:源 %d 字节、成片 %d 字节", srcSize, outSize)
|
|
}
|
|
t.Logf("抽帧大小:源 %d 字节、成片 %d 字节", srcSize, outSize)
|
|
}
|
|
|
|
// 同一次 .ass 还原只该在日志里出现一条(页面的「检查」和合并都会解析一遍)
|
|
func TestPrepareDanmuSourceLogsRestoreOnce(t *testing.T) {
|
|
setupMergeTest(t)
|
|
path := writeTestFile(t,
|
|
filepath.Join(recordSaveDir(), "2026-09-25 03-00-00 测试主播 标题.ass"), plainAssFixture)
|
|
|
|
var buf bytes.Buffer
|
|
oldOut := logger.Writer()
|
|
logger.SetOutput(&buf)
|
|
defer logger.SetOutput(oldOut)
|
|
|
|
// 「检查」先解析一遍(不落盘),合并再解析一遍并落盘
|
|
if _, err := prepareDanmuSource(path, false); err != nil {
|
|
t.Fatalf("检查失败:%v", err)
|
|
}
|
|
if _, err := prepareDanmuSource(path, true); err != nil {
|
|
t.Fatalf("还原失败:%v", err)
|
|
}
|
|
|
|
log := buf.String()
|
|
if n := strings.Count(log, "还原出"); n != 1 {
|
|
t.Fatalf("「还原出」应只出现 1 次,实际 %d 次:\n%s", n, log)
|
|
}
|
|
if n := strings.Count(log, "还原成"); n != 0 {
|
|
t.Fatalf("不该再有「还原成」这条日志,实际 %d 次:\n%s", n, log)
|
|
}
|
|
if !strings.Contains(log, filepath.Base(path)) {
|
|
t.Fatalf("日志里应写明是哪个文件:\n%s", log)
|
|
}
|
|
}
|