Files
acfunlive-remake/chunks_test.go
T
tamakyi 6f3c8096b4 修分块渲染的弹幕轨迹:跨块续画 + 文字头像同源,并补失败推送与输出帧率设置
1. 密集弹幕时头像偏离轨迹、弹幕突然加速、之后一段时间屏幕空白

   分块渲染的固有问题,三个毛病叠在一起:

   - writeChunkAss 把 \move 的起止时间截断到块边界(End=min(t0+10, chunk.end)),
     但 x2 写死成 -Width —— 等于把 10 秒的路挤进可见的那零点几秒里,文字直接飞过去。
     而头像 overlay 的 x 一直是「固定 /10 秒 + 真实 t0」,于是同一时刻文字已经走到
     左边、头像还在半路,看起来就是「头像掉队(移动变慢)」。
   - 块尾还会被单块上限收紧成「下一条弹幕出现的时间」,最极端时窗口只剩 0.01 秒。
   - 每块只画「本块新出现」的弹幕,跨块的续画没有:块头屏幕上只剩刚出现的几条,
     要 10 秒才重新填满 —— 就是「弹幕突然加速,然后一段时间屏幕上什么都没有」。

   改法:文字层改成按绝对时间算轨迹(danmuTextX / danmuAvatarX / avatarXExpr 同源),
   任何一块里的文字速度都等于真实速度;分块器改成「续画(起播时间在 start-10 ~ start)
   + 本块新出现的」,块尾取「下一条弹幕的起播时间」,这样每条弹幕只在唯一一块里算
   新出现,显示期间跨过的后面几块把它接着画,一帧都不缺。

   代价是每块要多画一倍多的弹幕,所以顺手把头像一个「绝对路径 + 每人一行 scale」改成
   「预缩放 + 短相对路径」(prepareAvatarInputs / scaleAvatarFile),每条从实测 241
   字符降到约 130,单块上限才能从 120 抬到 200(200 条 ≈ 26600,仍低于自检阈值 30000
   和 Windows 的 32767)。

2. 合并失败没有任何通知

   notify.go 新增 mergeFail 事件与 notifyMergeFailure(),接在 autoMerge() 和手动合并的
   失败分支上。正常结束、文件里没有弹幕、以及用户自己点的停止都不算失败,不推 ——
   最后一种推「合并失败」会误导。配置项 notifyMergeFail 默认开,且和 notifyEnabled 一样
   在 Unmarshal 之前预置 true,免得老设置文件升级后被静默关掉。

3. 低帧率录播里的弹幕一格一格跳

   新增设置项 mergeFps,默认 0 = 跟随源片(什么都不做)。只在目标高于源片帧率时才加
   fps 滤镜,而且必须加在字幕/头像之前 —— 加在之后只是把已经画好的几帧复制一遍,
   看起来还是卡。上限 240。

4. 顺带

   - -danmuburn 命令行模式不走 initialize(),exeDir 从来没被赋值:头像缓存会落到
     「当前工作目录/avatar_cache」,旧版还会把这个相对路径写进滤镜,而 ffmpeg 的工作
     目录是弹幕目录,报「打不开 avatar_cache\xxx.png」整块失败。
   - cleanTempFiles 见到目录就 continue,预缩放头像那个临时文件夹永远清不掉。
   - command_len_test 不再用会过期的「每条 241 字符」估算,改成真跑一遍最坏情况;
     探针的头像路径也改成真实的短相对路径(原来量的是早已不存在的长绝对路径)。
   - 新增 TestWebuiSettingFieldsMatchInputs:把页面里 JS 的 settingFields/numberFields
     抠出来,跟所有 input/select/textarea 的 id 比对,漏登记的设置项会被抓住。

验证

- gofmt / go vet / go test(174 个)/ go test -race / staticcheck / GOOS=linux go build 全过。
- 反向验证 32 项全过:把每个修复临时退回,对应测试确实失败。
- 真实片段实测(2023AcFun 晚会 120 秒高峰段,689 条弹幕,10 秒窗口内最多同时 140 条):
  旧 6 块 / 绘制 689 次 / 54.1% 的弹幕文字被加速(中位 2.12x,最差 500x)/
  54.2% 有轨迹缺口(中位缺 5.29 秒)/ 78.3s;
  新 5 块 / 绘制 984 次 / 加速 0% / 缺口 0% / 98.8s(+26%)。
  对照帧里旧版在块尾前 0.1 秒屏幕已经全空,块头后 0.4 秒仍然全空;新版一直是满屏。
- 输出帧率:15fps 源配 mergeFps=60,成片 60fps,1 秒内 60 帧哈希互不相同(不是复制帧);
  默认设置下成片仍是 15fps。
2026-09-26 16:34:47 +08:00

375 lines
13 KiB
Go
Raw 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 (
"errors"
"math"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// 密集弹幕:每 0.02 秒一条,10 秒停留窗口里能塞进 500 条。
// 块尾扩张原来没有上限,会把窗口里的弹幕几乎全并进同一块
func denseRecords(n int) []danmuRecord {
records := make([]danmuRecord, 0, n)
for i := 0; i < n; i++ {
records = append(records, danmuRecord{
Time: float64(i) * 0.02,
UID: int64(i%5 + 1),
Name: "密集测试",
Content: "弹幕内容",
Lane: i % 10,
})
}
return records
}
// 单块条数必须被硬上限挡住(续画 + 本块新出现的条数一起算),否则 -filter_complex
// 会撑爆命令行长度。
//
// 「新出现」的那些条按顺序拼起来必须正好是原始列表:续画只是同一批弹幕在后面几块里
// 接着画,不能算成新弹幕,更不能有哪一条一次都没算成新出现(那就整条丢了)
func TestSplitDanmuChunksCapsBlockSize(t *testing.T) {
records := denseRecords(600)
chunks := splitDanmuChunks(records, 0, 0)
if len(chunks) < 2 {
t.Fatalf("这么多弹幕应当分成多块,实际 %d 块", len(chunks))
}
fresh := make([]danmuRecord, 0, len(records))
for i, ch := range chunks {
if n := len(ch.records); n > maxDanmuPerChunkHard {
t.Fatalf("第 %d 块有 %d 条弹幕,超过硬上限 %d", i+1, n, maxDanmuPerChunkHard)
}
if ch.carried < 0 || ch.carried > len(ch.records) {
t.Fatalf("第 %d 块的 carried=%d 越界(这一块共 %d 条)", i+1, ch.carried, len(ch.records))
}
fresh = append(fresh, ch.records[ch.carried:]...)
}
if len(fresh) != len(records) {
t.Fatalf("新出现的弹幕总数应当是 %d,实际 %d", len(records), len(fresh))
}
for i := range records {
if fresh[i].Time != records[i].Time || fresh[i].Content != records[i].Content {
t.Fatalf("第 %d 条新弹幕对不上:拿到 %+v,期望 %+v", i, fresh[i], records[i])
}
}
}
// 一条弹幕停留 10 秒,块边界经常从它中间切过去:分块器必须把它「续画」到后面几块里。
// 不续画的话每个块头屏幕上只剩刚出现的那几条,要 10 秒才重新填满 —— 观众看到的就是
// 「弹幕突然加速冲过去,然后一段时间屏幕上什么都没有」
func TestSplitDanmuChunksCarriesOnScreenDanmu(t *testing.T) {
records := denseRecords(600) // 每 0.02 秒一条,10 秒窗口里同时有 500 条
chunks := splitDanmuChunks(records, 0, 0)
carriedTotal, fresh := 0, 0
for i, ch := range chunks {
carriedTotal += ch.carried
fresh += len(ch.records) - ch.carried
for _, r := range ch.records[:ch.carried] {
// 续画只能是「上几块里已经出现过、此刻还在屏上」的那些
if r.Time >= ch.start {
t.Fatalf("第 %d 块(起点 %.3f)把时间 %.3f 的弹幕当成了续画,它不是上一块留下来的",
i+1, ch.start, r.Time)
}
if r.Time < ch.start-danmuShowSeconds {
t.Fatalf("第 %d 块(起点 %.3f)续画了时间 %.3f 的弹幕,它早就滚出屏了",
i+1, ch.start, r.Time)
}
}
}
if carriedTotal == 0 {
t.Fatal("这么密的弹幕不可能完全没有需要续画的块,续画逻辑没生效")
}
if fresh != len(records) {
t.Fatalf("新出现的弹幕数不对:%d,期望 %d", fresh, len(records))
}
}
// 块与块必须首尾相接,而且每条弹幕的 10 秒停留窗口要在所有块里被完整画满:
// 中间只要有缺口,观众就会看到那条弹幕闪一下又消失
func TestSplitDanmuChunksCoversEveryShowWindow(t *testing.T) {
// 每 0.5 秒一条:10 秒窗口里同时有 20 条,续画远放得下,不会触发「挤掉最早的几条」
records := make([]danmuRecord, 600)
for i := range records {
records[i] = danmuRecord{Time: float64(i) * 0.5, UID: 1, Name: "甲", Content: "弹幕", Lane: i % 10}
}
chunks := splitDanmuChunks(records, 0, 0)
for i := 1; i < len(chunks); i++ {
if chunks[i].start != chunks[i-1].end {
t.Fatalf("第 %d 块和第 %d 块之间有空隙:%.3f -> %.3f",
i, i+1, chunks[i-1].end, chunks[i].start)
}
}
for _, r := range records {
covered := r.Time
for _, ch := range chunks {
vs, ve, ok := danmuVisibleInChunk(r, 0, ch)
if !ok {
continue
}
if vs > covered+1e-9 {
t.Fatalf("时间 %.3f 的弹幕在 %.3f~%.3f 之间没被画出来(前后两块没接上)",
r.Time, covered, vs)
}
if ve > covered {
covered = ve
}
}
if covered < r.Time+danmuShowSeconds-1e-9 {
t.Fatalf("时间 %.3f 的弹幕只画到 %.3f,10 秒停留窗口没画满", r.Time, covered)
}
}
}
// 同一时刻挤了一堆弹幕(时间戳重复)时块尾会和块头重合,那种块渲染出来一帧都没有,
// 拼进成片就是一段黑屏/缺帧
func TestSplitDanmuChunksNeverProducesEmptySpan(t *testing.T) {
records := make([]danmuRecord, 0, 400)
for i := 0; i < 400; i++ {
records = append(records, danmuRecord{
Time: 5, UID: 1, Name: "甲", Content: "同一时刻", Lane: i % 10, Width: 100,
})
}
chunks := splitDanmuChunks(records, 0, 0)
if len(chunks) < 2 {
t.Fatalf("400 条应当分成多块,实际 %d 块", len(chunks))
}
for i, ch := range chunks {
if math.IsInf(ch.end, 1) {
continue
}
if ch.end <= ch.start {
t.Fatalf("第 %d 块的时间范围是空的(%.3f ~ %.3f),渲染出来一帧都没有",
i+1, ch.start, ch.end)
}
}
}
// 回退成「只合并第一个分段」时,used 必须只剩第一段:
// 自动合并收尾按 used 删源视频,返回全部就等于把没进成片的后半场删掉
func TestPrepareForMergeOnlyMarksUsedSegments(t *testing.T) {
dir := t.TempDir()
a := filepath.Join(dir, "a.mp4")
b := filepath.Join(dir, "b.mp4")
for _, p := range []string{a, b} {
if err := os.WriteFile(p, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
}
jsonl := filepath.Join(dir, "a.jsonl")
if err := os.WriteFile(jsonl, []byte("{}\n"), 0644); err != nil {
t.Fatal(err)
}
// 用一个不存在的 ffmpeg:probeDuration 拿不到时长,触发「只合并第一个分段」的回退
segs := []recordSegment{{file: a, startNs: 0}, {file: b, startNs: int64(time.Second)}}
video, _, _, used, cleanup, err := prepareForMerge(filepath.Join(dir, "没有这个-ffmpeg.exe"), segs, jsonl, 0)
defer cleanup()
if err != nil {
t.Fatalf("回退不该报错:%v", err)
}
if video != a {
t.Fatalf("回退后应当合并第一段,实际 %s", video)
}
if len(used) != 1 || used[0] != a {
t.Fatalf("回退成只合并第一段时 used 应只含第一段,实际 %v", used)
}
}
// 直接改全局任务状态:测试里只碰 stopped 这一个字段,用完恢复
func setJobStopped(t *testing.T, v bool) {
t.Helper()
job.Lock()
prev := job.stopped
job.stopped = v
job.Unlock()
t.Cleanup(func() {
job.Lock()
job.stopped = prev
job.Unlock()
})
}
// 现场生成一个 1 秒的小视频分段
func makeTinyVideo(t *testing.T, ffmpegFile, path, color string) {
t.Helper()
if err := runFFmpegWith(ffmpegFile, []string{
"-y", "-f", "lavfi", "-i", "color=c=" + color + ":s=160x120:d=1:r=10",
"-c:v", "libx264", "-pix_fmt", "yuv420p", path,
}, filepath.Dir(path), nil); err != nil {
t.Skipf("生成测试分段失败,跳过:%v", err)
}
}
// 多段都拿到时长、成功拼接时,used 应当是全部分段(原来的删除行为不变)
func TestPrepareForMergeMarksAllSegmentsWhenJoined(t *testing.T) {
ffmpegFile := getFFmpeg()
if ffmpegFile == "" {
t.Skip("找不到 ffmpeg,跳过")
}
setJobStopped(t, false)
dir := t.TempDir()
a, b := filepath.Join(dir, "a.mp4"), filepath.Join(dir, "b.mp4")
makeTinyVideo(t, ffmpegFile, a, "red")
makeTinyVideo(t, ffmpegFile, b, "blue")
jsonl := filepath.Join(dir, "a.jsonl")
if err := os.WriteFile(jsonl, []byte(`{"playResX": 160, "playResY": 120, "fontSize": 20, "startTime": 0}`+"\n"+
`{"t": 0.2, "uid": 1, "name": "n", "avatar": "", "content": "c", "lane": 0, "width": 0}`+"\n"), 0644); err != nil {
t.Fatal(err)
}
segs := []recordSegment{{file: a, startNs: 0}, {file: b, startNs: int64(time.Second)}}
video, out, _, used, cleanup, err := prepareForMerge(ffmpegFile, segs, jsonl, 0)
defer cleanup()
if err != nil {
t.Fatalf("拼接两个正常分段不该报错:%v", err)
}
if video != filepath.Join(dir, chunkTempPrefix+"joined.mp4") {
t.Fatalf("应当用拼接后的文件合并,实际 %s", video)
}
if len(used) != 2 {
t.Fatalf("拼接成功时 used 应包含全部分段,实际 %v", used)
}
if _, err := os.Stat(out); err != nil {
t.Fatalf("重映射的弹幕文件应当存在:%v", err)
}
// cleanup 必须把中间文件收干净
cleanup()
if _, err := os.Stat(video); err == nil {
t.Fatalf("清理后拼接出来的中间视频还在:%s", video)
}
if _, err := os.Stat(out); err == nil {
t.Fatalf("清理后重映射的弹幕文件还在:%s", out)
}
}
// 拼接中途被停止:要报成 errJobStopped,并且把拼到一半的中间文件删掉
// (留在录播目录里既占几 GB 又带 chunkTempPrefix,界面文件列表里根本看不见)
func TestPrepareForMergeDropsJoinedWhenStopped(t *testing.T) {
ffmpegFile := getFFmpeg()
if ffmpegFile == "" {
t.Skip("找不到 ffmpeg,跳过")
}
setJobStopped(t, false)
dir := t.TempDir()
a, b := filepath.Join(dir, "a.mp4"), filepath.Join(dir, "b.mp4")
makeTinyVideo(t, ffmpegFile, a, "red")
makeTinyVideo(t, ffmpegFile, b, "blue")
jsonl := filepath.Join(dir, "a.jsonl")
if err := os.WriteFile(jsonl, []byte("{}\n"), 0644); err != nil {
t.Fatal(err)
}
joined := filepath.Join(dir, chunkTempPrefix+"joined.mp4")
if err := os.WriteFile(joined, []byte("拼到一半的内容"), 0644); err != nil {
t.Fatal(err)
}
setJobStopped(t, true)
segs := []recordSegment{{file: a, startNs: 0}, {file: b, startNs: int64(time.Second)}}
_, _, _, used, cleanup, err := prepareForMerge(ffmpegFile, segs, jsonl, 0)
defer cleanup()
if !errors.Is(err, errJobStopped) {
t.Fatalf("被停止时应当返回 errJobStopped,实际 %v", err)
}
if len(used) != 0 {
t.Fatalf("被停止时不该把任何分段算作已合并,实际 %v", used)
}
if _, err := os.Stat(joined); err == nil {
t.Fatalf("停止后拼到一半的中间文件应当被删掉:%s", joined)
}
}
// 任务处于「已请求停止」时,拼接失败要报成 errJobStopped,而不是「拼接失败」
func TestConcatChunksReportsStopped(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "in.mp4")
if err := os.WriteFile(f, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
out := filepath.Join(dir, "out.mp4")
setJobStopped(t, true)
err := concatChunks(filepath.Join(dir, "没有这个-ffmpeg.exe"), []string{f}, out, dir, nil)
if !errors.Is(err, errJobStopped) {
t.Fatalf("停止状态下拼接失败应当返回 errJobStopped,实际 %v", err)
}
}
// 没有停止请求时,拼接失败就是普通错误,别报成「已停止」
func TestConcatChunksReportsFailureWhenNotStopped(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "in.mp4")
if err := os.WriteFile(f, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
out := filepath.Join(dir, "out.mp4")
setJobStopped(t, false)
err := concatChunks(filepath.Join(dir, "没有这个-ffmpeg.exe"), []string{f}, out, dir, nil)
if err == nil {
t.Fatal("拼接应当失败")
}
if errors.Is(err, errJobStopped) {
t.Fatalf("没有停止请求时不该报成已停止:%v", err)
}
if !strings.Contains(err.Error(), "拼接") {
t.Fatalf("错误信息应当说明是拼接失败,实际 %v", err)
}
}
// 确定性失败不该白白重试三次
func TestRunThriceSkipsNoRetry(t *testing.T) {
calls := 0
err := runThrice(func() error {
calls++
return noRetry(errors.New("确定性失败"))
})
if calls != 1 {
t.Fatalf("标记为不重试的错误应当只尝试一次,实际 %d 次", calls)
}
if err == nil || !strings.Contains(err.Error(), "确定性失败") {
t.Fatalf("错误应当原样返回,实际 %v", err)
}
if strings.Contains(err.Error(), "运行三次") {
t.Fatalf("不该被包装成「运行三次都出现错误」:%v", err)
}
}
// 普通错误仍然照旧重试
func TestRunThriceStillRetriesNormalError(t *testing.T) {
calls := 0
err := runThrice(func() error {
calls++
if calls == 2 {
return nil
}
return errors.New("暂时失败")
})
if err != nil {
t.Fatalf("第二次成功时不该报错:%v", err)
}
if calls != 2 {
t.Fatalf("普通错误应当重试,实际只尝试了 %d 次", calls)
}
}
// 交给 exec 的 ffmpeg 路径必须是绝对路径:
// Windows 上 exec 会直接拒绝执行「相对当前目录」找到的可执行文件
func TestGetFFmpegReturnsAbsPath(t *testing.T) {
p := getFFmpeg()
if p == "" {
t.Skip("本机找不到 ffmpeg,跳过")
}
if !filepath.IsAbs(p) {
t.Fatalf("getFFmpeg 应当返回绝对路径,实际 %q", p)
}
}