From 3e16e78003940253a57dfadaf42725f1e26fddf4 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Sat, 26 Sep 2026 17:51:43 +0800 Subject: [PATCH] =?UTF-8?q?.ass=20=E6=97=B6=E9=97=B4=E5=9F=BA=E5=87=86?= =?UTF-8?q?=E5=92=8C=E8=A7=86=E9=A2=91=E5=AF=B9=E4=B8=8D=E4=B8=8A=E6=97=B6?= =?UTF-8?q?=E6=8C=89=E7=AC=AC=E4=B8=80=E6=9D=A1=E5=BC=B9=E5=B9=95=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=AF=B9=E9=BD=90=EF=BC=8C=E5=B9=B6=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E7=9A=84=E8=BF=98=E5=8E=9F=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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), 头像跟着文字同步走,没有回归。 --- ass2jsonl.go | 24 ++--- danmuburn.go | 72 ++++++++++++--- manualmerge.go | 24 ++++- manualmerge_test.go | 8 +- p2_fixes_test.go | 2 +- recordjsonl_test.go | 33 ++++++- utils.go | 9 +- videofit_test.go | 219 ++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 353 insertions(+), 38 deletions(-) create mode 100644 videofit_test.go diff --git a/ass2jsonl.go b/ass2jsonl.go index f7365cb..79f5d0f 100644 --- a/ass2jsonl.go +++ b/ass2jsonl.go @@ -139,12 +139,14 @@ func speakerText(name string, uid int64, content string) string { } // 从 ASS 文件还原弹幕记录,支持原格式(\move)和昵称头像版(\an7\move) -func parseAssFile(path string) (danmuMeta, []danmuRecord, error) { +// 第二个返回值是没能解析出来的行数,日志交给调用方打:页面的「检查」和真正的合并 +// 会各解析一遍同一个 .ass,日志只该出现一次 +func parseAssFile(path string) (danmuMeta, []danmuRecord, int, error) { var meta danmuMeta f, err := os.Open(path) if err != nil { - return meta, nil, err + return meta, nil, 0, err } defer f.Close() @@ -275,33 +277,27 @@ func parseAssFile(path string) (danmuMeta, []danmuRecord, error) { } } if err := scanner.Err(); err != nil { - return meta, nil, err + return meta, nil, bad, err } if meta.PlayResX == 0 || meta.PlayResY == 0 { - return meta, nil, errors.New("ASS 文件里没有 PlayResX / PlayResY,可能不是弹幕文件") + return meta, nil, bad, errors.New("ASS 文件里没有 PlayResX / PlayResY,可能不是弹幕文件") } if meta.FontSize == 0 { - return meta, nil, errors.New("ASS 文件里没找到字幕字号(Style: Danmu)") + return meta, nil, bad, errors.New("ASS 文件里没找到字幕字号(Style: Danmu)") } if len(records) == 0 { if bad > 0 { - return meta, nil, fmt.Errorf("ASS 里的 %d 行弹幕都没能解析出来", bad) + return meta, nil, bad, fmt.Errorf("ASS 里的 %d 行弹幕都没能解析出来", bad) } - return meta, nil, errNoDanmu + return meta, nil, bad, errNoDanmu } // 分块渲染要求记录按时间递增,这里统一排一遍 sort.SliceStable(records, func(i, j int) bool { return records[i].Time < records[j].Time }) // 文件名前缀就是录制开始的时刻,用它算偏移最稳 meta.StartTime = nameStartNs(path) - if bad > 0 { - lPrintWarnf("%s 里有 %d 行弹幕没能解析出来,已跳过", filepath.Base(path), bad) - } - lPrintf("已从 %s 还原出 %d 条弹幕(%dx%d,字号 %d,弹幕到 %s)", - filepath.Base(path), len(records), meta.PlayResX, meta.PlayResY, meta.FontSize, - formatSeconds(records[len(records)-1].Time)) - return meta, records, nil + return meta, records, bad, nil } // 按本程序的 jsonl 格式写文件(首行是元数据) diff --git a/danmuburn.go b/danmuburn.go index da8b240..6023b22 100644 --- a/danmuburn.go +++ b/danmuburn.go @@ -287,6 +287,64 @@ func runDanmuburn(danmuFile, videoFile, outFile string, offset float64) { dropTempDanmu(src) } +// 按偏移算出视频时间,落在视频时长之外的丢掉;返回留下的弹幕和被丢掉的条数 +func clipDanmuToVideo(records []danmuRecord, offset, videoDuration float64) ([]danmuRecord, int) { + kept := make([]danmuRecord, 0, len(records)) + for _, r := range records { + if r.Time+offset < videoDuration { + kept = append(kept, r) + } + } + return kept, len(records) - len(kept) +} + +// 把弹幕对齐到视频的时间轴。 +// 正常情况就是「超出视频时长的部分丢掉」;但 upstream 录出来的 .ass 时间不一定相对录制起点 +// (程序先开着、直播中途才开始录的场次,弹幕时间基准会比视频晚一大截), +// 那种场次会被整体判成「超出时长」然后一条都合不出来,这里按「第一条弹幕对齐视频起点」平移救回来。 +// 只在「一条都用不上」且「弹幕跨度装得进视频」时才平移,本来就能合成的场次完全不受影响。 +// 返回调整后的弹幕和偏移(后续渲染都用这个偏移)。 +func fitDanmuToVideo(records []danmuRecord, offset, videoDuration float64) ([]danmuRecord, float64) { + kept, dropped := clipDanmuToVideo(records, offset, videoDuration) + if dropped == 0 { + return records, offset + } + if len(kept) > 0 { + // 只是一部分超出(弹幕确实录得比视频久),保持原行为:丢掉超出的那部分 + lPrintWarnf("有 %d 条弹幕超出了录播视频的时长(%.1f 秒),已跳过", dropped, videoDuration) + return kept, offset + } + + first, last := records[0].Time, records[0].Time + for _, r := range records[1:] { + if r.Time < first { + first = r.Time + } + if r.Time > last { + last = r.Time + } + } + late := first + offset // 最早的那条比视频起点晚多少秒(走到这里必然 ≥ 视频时长) + if last-first >= videoDuration { + // 弹幕跨度本身就装不进视频:平移也救不回来 + lPrintWarnf("有 %d 条弹幕超出了录播视频的时长(%.1f 秒),已跳过", dropped, videoDuration) + return nil, offset + } + + newOffset := -first + lPrintWarnf("弹幕时间基准和视频对不上:%d 条弹幕全部落在视频时长(%.1f 秒)之外,最早那条在 %.1f 秒;"+ + "已按「第一条弹幕对齐视频起点」整体前移 %.1f 秒(偏移 %.1f → %.1f)", + len(records), videoDuration, late, late, offset, newOffset) + + // 跨度装得进视频,所以平移之后不会再丢弹幕 + kept, _ = clipDanmuToVideo(records, newOffset, videoDuration) + start, end := kept[0].Time+newOffset, kept[len(kept)-1].Time+newOffset + lPrintf("对齐后弹幕覆盖 %.1f ~ %.1f 秒,视频总长 %.1f 秒(尾部空 %.1f 秒);"+ + "如果弹幕整体偏早或偏晚,在「偏移」里微调后重新合并", + start, end, videoDuration, videoDuration-end) + return kept, newOffset +} + // 合成一个 jsonl(和可选的视频)为带头像的弹幕层 // videoFile 为空时输出透明头像层(ProRes 4444),否则把弹幕层压进视频 func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error { @@ -362,21 +420,13 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error { return errors.New("找不到 ffmpeg,请先安装并加入 PATH,或把 ffmpeg 放在本程序所在文件夹,或在设置里填写 ffmpeg 路径") } - // 弹幕可能录得比视频久(视频中途中断过),超出视频时长的部分没法渲染,直接丢掉 + // 弹幕可能录得比视频久(视频中途中断过),超出视频时长的部分没法渲染,直接丢掉; + // 时间基准整体偏移的 .ass(见 fitDanmuToVideo)在这一步救回来 videoDuration := 0.0 if burnMode { videoDuration = probeDuration(ffmpegFile, videoFile) if videoDuration > 0 { - kept := records[:0] - for _, r := range records { - if r.Time+offset < videoDuration { - kept = append(kept, r) - } - } - if dropped := len(records) - len(kept); dropped > 0 { - lPrintWarnf("有 %d 条弹幕超出了录播视频的时长(%.1f 秒),已跳过", dropped, videoDuration) - } - records = kept + records, offset = fitDanmuToVideo(records, offset, videoDuration) if len(records) == 0 { return errNoDanmu } diff --git a/manualmerge.go b/manualmerge.go index 4ad2563..54cd85e 100644 --- a/manualmerge.go +++ b/manualmerge.go @@ -278,7 +278,7 @@ func prepareDanmuSource(path string, write bool) (danmuSource, error) { } } - meta, records, err := parseAssFile(path) + meta, records, bad, err := parseAssFile(path) if err != nil { return danmuSource{}, err } @@ -289,6 +289,12 @@ func prepareDanmuSource(path string, write bool) (danmuSource, error) { return src, nil } + // 还原日志只在真正落盘这次打:页面的「检查」也会解析一遍同一个 .ass, + // 两处都打日志的话同一次还原会在日志里出现两遍 + summary := fmt.Sprintf("已从 %s 还原出 %d 条弹幕(%dx%d,字号 %d,弹幕到 %s)", + filepath.Base(path), len(records), meta.PlayResX, meta.PlayResY, meta.FontSize, + formatSeconds(records[len(records)-1].Time)) + if err := writeJsonlFile(jsonlPath, meta, records); err != nil { // 目录只读等情况退到临时目录,不影响这次合并 tmp := filepath.Join(os.TempDir(), chunkTempPrefix+"converted.jsonl") @@ -298,12 +304,22 @@ func prepareDanmuSource(path string, write bool) (danmuSource, error) { lPrintWarnf("无法把还原结果写入 %s(%v),改用临时文件 %s", jsonlPath, err, tmp) src.jsonlPath = tmp src.tempJsonl = true + lPrintf("%s,写成 %s", summary, filepath.Base(tmp)) + logAssBadLines(path, bad) return src, nil } - lPrintf("已把 %s 还原成 %s", filepath.Base(path), filepath.Base(jsonlPath)) + lPrintf("%s,写成 %s", summary, filepath.Base(jsonlPath)) + logAssBadLines(path, bad) return src, nil } +// .ass 里有行没能解析出来时提醒一次(只在真正落盘那次调,避免重复) +func logAssBadLines(path string, bad int) { + if bad > 0 { + lPrintWarnf("%s 里有 %d 行弹幕没能解析出来,已跳过", filepath.Base(path), bad) + } +} + // .ass 还原结果退到系统临时目录时,用完就删。只删标记过的临时文件: // 正常情况 jsonlPath 是用户录播目录里的同名 jsonl,删了就是丢数据 func dropTempDanmu(src danmuSource) { @@ -460,7 +476,9 @@ func buildMergePlan(req mergeRequest) (mergePlan, error) { if info.Duration > 0 && src.last+info.Offset >= info.Duration { info.Warnings = append(info.Warnings, fmt.Sprintf( "最后一条弹幕在 %.1f 秒,加上偏移后已超出视频总长 %.1f 秒,超出的弹幕会被丢掉;"+ - "如果这个视频只是整场的一段,请把同场的其它分段一起加进来", + "如果这个视频只是整场的一段,请把同场的其它分段一起加进来;"+ + "如果弹幕整体都落在时长之外,通常是这个弹幕文件的时间基准和视频不一致"+ + "(合成时会按第一条弹幕自动对齐,日志里有平移量),也可以自己填「偏移」", src.last+info.Offset, info.Duration)) } if info.Duration == 0 { diff --git a/manualmerge_test.go b/manualmerge_test.go index 86bfa96..378092c 100644 --- a/manualmerge_test.go +++ b/manualmerge_test.go @@ -163,7 +163,7 @@ func TestParseAssFilePlain(t *testing.T) { dir := setupMergeTest(t) path := writeTestFile(t, filepath.Join(dir, "2026-09-25 01-00-00 测试主播 标题.ass"), plainAssFixture) - meta, records, err := parseAssFile(path) + meta, records, _, err := parseAssFile(path) if err != nil { t.Fatalf("解析失败:%v", err) } @@ -203,7 +203,7 @@ func TestParseAssFileNick(t *testing.T) { dir := setupMergeTest(t) path := writeTestFile(t, filepath.Join(dir, "2026-09-25 01-00-00 测试主播 标题.nick.ass"), nickAssFixture) - meta, records, err := parseAssFile(path) + meta, records, _, err := parseAssFile(path) if err != nil { t.Fatalf("解析失败:%v", err) } @@ -238,7 +238,7 @@ func TestParseAssFileWithoutFormatLine(t *testing.T) { dir := setupMergeTest(t) path := writeTestFile(t, filepath.Join(dir, "noformat.ass"), noFormatAssFixture) - meta, records, err := parseAssFile(path) + meta, records, _, err := parseAssFile(path) if err != nil { t.Fatalf("解析失败:%v", err) } @@ -267,7 +267,7 @@ func TestParseAssFileRejectsBadFile(t *testing.T) { } for name, content := range cases { path := writeTestFile(t, filepath.Join(dir, name), content) - if _, _, err := parseAssFile(path); err == nil { + if _, _, _, err := parseAssFile(path); err == nil { t.Fatalf("%s 应该报错", name) } } diff --git a/p2_fixes_test.go b/p2_fixes_test.go index 86b182c..60a1945 100644 --- a/p2_fixes_test.go +++ b/p2_fixes_test.go @@ -175,7 +175,7 @@ func TestParseAssFileAcceptsBOM(t *testing.T) { t.Fatal(err) } - meta, records, err := parseAssFile(p) + meta, records, _, err := parseAssFile(p) if err != nil { t.Fatalf("带 BOM 的 ASS 应当能正常解析,实际报错:%v", err) } diff --git a/recordjsonl_test.go b/recordjsonl_test.go index 06c1fd6..6c2ffd5 100644 --- a/recordjsonl_test.go +++ b/recordjsonl_test.go @@ -8,12 +8,28 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "time" "github.com/orzogc/acfundanmu" ) +// 等替换过的后台任务全部收工。用例里触发了 fire-and-forget 的合并时,必须在用例结束前等它们跑完 +func waitBackground(t *testing.T, bg *sync.WaitGroup, timeout time.Duration) { + t.Helper() + done := make(chan struct{}) + go func() { + bg.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(timeout): + t.Error("等后台任务收工超时,它可能还在读全局配置") + } +} + // 用真正的录制写入器(danmuWriter)产生 jsonl,再按合并的流程重排一遍, // 验证「本程序自己录下来的弹幕」合出来和以前一模一样(弹道、宽度都不变)。 // 这也是手动合并与自动合并的分水岭:录制侧写文件完全不经过 relayoutLanes, @@ -183,12 +199,23 @@ func TestDanmuWriteNoFalseAlarm(t *testing.T) { // 弹幕文件写坏之后必须跳过自动合并:只是打一条日志的话, // 照样会合成一个「缺了一大截弹幕」的成片,而且是静默的 func TestFinishPartSkipsMergeWhenDanmuBroken(t *testing.T) { + // 这个用例会真的触发自动合并(safeGo 起的 goroutine)。它跑到用例结束之后还会读全局配置, + // 而下一个用例开头就改配置 → -race 会判成数据竞争。所以这里把后台任务换成可等待的版本, + // 清理时先等它收工再还原全局变量 + var bg sync.WaitGroup + oldRun := runBackground + runBackground = func(f func()) { + bg.Add(1) + go func() { + defer bg.Done() + f() + }() + } oldAuto, oldCtx := config.AutoMerge, mainCtx t.Cleanup(func() { + waitBackground(t, &bg, 60*time.Second) config.AutoMerge, mainCtx = oldAuto, oldCtx - // 等后台那两个(可能启动了的)合并跑完,别把任务状态留给后面的用例 - takeMergeSlot() - releaseMergeSlot() + runBackground = oldRun }) config.AutoMerge = true mainCtx = context.Background() diff --git a/utils.go b/utils.go index c5ba536..e350016 100644 --- a/utils.go +++ b/utils.go @@ -107,17 +107,22 @@ func checkErr(err error) { } } +// 后台任务真正怎么起。单独抽成一个变量是为了让测试能换成「可等待」的版本: +// fire-and-forget 的 goroutine 漏到测试结束之后,会在下一个测试改全局配置时读它们, +// -race 会判成数据竞争 +var runBackground = func(f func()) { go f() } + // 后台任务的统一入口:兜底 recover,避免单个任务的 panic 终止整个进程 // name 用于日志里指出是哪个任务出错,f 里可以继续做自己的 recover func safeGo(name string, f func()) { - go func() { + runBackground(func() { defer func() { if err := recover(); err != nil { lPrintErrf("%s 发生错误并已恢复,其它录制任务不受影响:%v", name, err) } }() f() - }() + }) } // 不需要重试的错误:再试一遍也只是重复同样的失败(比如头像本身就超出体积/尺寸上限), diff --git a/videofit_test.go b/videofit_test.go new file mode 100644 index 0000000..bbd7147 --- /dev/null +++ b/videofit_test.go @@ -0,0 +1,219 @@ +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) + } +}