package main import "testing" // 还原自 .ass 的弹幕:原格式的弹道是按「只有内容」的宽度占位的,渲染用的是 // 「昵称(uid):内容 + 头像」的宽度,占位更长 → 同弹道会追尾。重排后必须分开。 func TestRelayoutSeparatesRestoredOverlap(t *testing.T) { meta := danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 60} content := "哈哈哈哈哈哈哈哈" // 两条原本同弹道、间隔 2.5 秒:按内容宽度(8×60=480)算,2.5 秒时前一条已经让位; // 按昵称头像宽度(头像48+间隔10+文本720)算,前一条要到约 2.9 秒才让位 records := []danmuRecord{ {Time: 0, UID: 101, Name: "甲", Content: content, Lane: 0, Width: 480}, {Time: 2.5, UID: 202, Name: "乙", Content: content, Lane: 0, Width: 480}, } relayoutLanes(records, meta) if records[0].Lane == records[1].Lane { t.Fatalf("两条追尾的弹幕重排后仍在同一条弹道 %d", records[0].Lane) } // 宽度也要按昵称头像版重算,否则滚动速度还是错的 want := textWidth(speakerText("甲", 101, content), meta.FontSize) if records[0].Width != want { t.Errorf("宽度应重算为 %d,实际 %d", want, records[0].Width) } } // 录制时存下来的弹道本来就是按同一套公式算的,重排应当原样保留 func TestRelayoutKeepsLiveLayout(t *testing.T) { meta := danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 48} var records []danmuRecord for i := 0; i < 40; i++ { records = append(records, danmuRecord{ Time: float64(i) * 0.4, UID: int64(100 + i), Name: "观众", Content: "测试弹幕内容", }) } relayoutLanes(records, meta) first := make([]int, len(records)) for i, r := range records { first[i] = r.Lane } relayoutLanes(records, meta) for i, r := range records { if r.Lane != first[i] { t.Fatalf("第 %d 条重排两次结果不一致:%d → %d", i, first[i], r.Lane) } } } // 输入顺序乱了也要按时间重排,否则弹道占用顺序错乱 func TestRelayoutSortsByTime(t *testing.T) { meta := danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 48} shuffled := []danmuRecord{ {Time: 5, UID: 1, Name: "甲", Content: "第三条"}, {Time: 1, UID: 2, Name: "乙", Content: "第一条"}, {Time: 3, UID: 3, Name: "丙", Content: "第二条"}, } sorted := []danmuRecord{ {Time: 1, UID: 2, Name: "乙", Content: "第一条"}, {Time: 3, UID: 3, Name: "丙", Content: "第二条"}, {Time: 5, UID: 1, Name: "甲", Content: "第三条"}, } relayoutLanes(shuffled, meta) relayoutLanes(sorted, meta) for i := range sorted { if shuffled[i].Lane != sorted[i].Lane { t.Errorf("第 %d 条弹道不一致:%d vs %d", i, shuffled[i].Lane, sorted[i].Lane) } } } // 字号越小,同一条弹幕在屏幕上停留时占用的时间越短,弹道占用也更宽松 func TestRelayoutWidthScalesWithFontSize(t *testing.T) { big := []danmuRecord{{Time: 0, UID: 7, Name: "甲", Content: "短弹幕"}} small := []danmuRecord{{Time: 0, UID: 7, Name: "甲", Content: "短弹幕"}} relayoutLanes(big, danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 60}) relayoutLanes(small, danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 30}) if big[0].Width <= small[0].Width { t.Errorf("字号 60 的宽度 %d 应大于字号 30 的 %d", big[0].Width, small[0].Width) } if big[0].Width != small[0].Width*2 { t.Errorf("宽度应随字号等比缩放:60 → %d,30 → %d", big[0].Width, small[0].Width) } } // 多分段(断流重连)拼接时,remapJsonlTimes 只改时间、保留各段自己算的弹道, // 两段各自都从 0 号弹道开始排,时间压缩到同一条轴之后必然互相压。重排按拼接后 // 的时间轴统一分配,这种跨段冲突也会一并消掉。 func TestRelayoutFixesConcatenatedSegmentConflict(t *testing.T) { meta := danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 48} mk := func(base float64) []danmuRecord { var out []danmuRecord for i := 0; i < 12; i++ { out = append(out, danmuRecord{ Time: base + float64(i)*0.4, UID: int64(3000 + i), Name: "观众", Content: "断流前后各自的弹幕", }) } return out } // 两段各自从 0 秒开始排(录制时就是这样),拼接后第二段紧接第一段 first := mk(0) relayoutLanes(first, meta) second := mk(4.8) relayoutLanes(second, meta) records := append(append([]danmuRecord{}, first...), second...) // 前提:这种拼法本来就撞(第二段的 0 号弹道紧接第一段的 0 号弹道) if overlaps := countLaneOverlaps(records, meta); overlaps == 0 { t.Fatal("两段直接拼起来本应有同弹道冲突,用例前提不成立") } relayoutLanes(records, meta) if overlaps := countLaneOverlaps(records, meta); overlaps != 0 { t.Errorf("重排后仍有 %d 条同弹道冲突", overlaps) } } // 画布短边:横屏取高、竖屏取宽 func TestShortSide(t *testing.T) { cases := []struct { w, h, want int }{ {1920, 1080, 1080}, {1080, 1920, 1080}, {720, 1280, 720}, {1280, 720, 720}, {1080, 1080, 1080}, {0, 1080, 0}, {1920, -1, 0}, } for _, c := range cases { if got := shortSide(c.w, c.h); got != c.want { t.Errorf("shortSide(%d,%d) 应为 %d,实际 %d", c.w, c.h, c.want, got) } } } // 自动字号 = 画布短边的 5.56%。竖屏必须按宽度算 —— // 按高度算的话 720x1280 会得到 71,一条弹幕就铺满整行。 func TestAutoFontSize(t *testing.T) { cases := []struct { vw, vh, want int }{ {1920, 1080, 60}, {1280, 720, 40}, {960, 540, 30}, {720, 1280, 40}, {640, 1280, 36}, {1080, 1920, 60}, {1080, 1080, 60}, // 方形按宽算 {0, 1080, 0}, // 拿不到尺寸 {1920, 0, 0}, } for _, c := range cases { if got := autoFontSize(c.vw, c.vh); got != c.want { t.Errorf("autoFontSize(%d,%d) 应为 %d,实际 %d", c.vw, c.vh, c.want, got) } } } // 录制端的档位预设必须和自动字号规则一致,否则录出来的弹幕文件和成片对不上 func TestSubConfigsMatchAutoFontSize(t *testing.T) { for key, cfg := range subConfigs { want := autoFontSize(cfg.PlayResX, cfg.PlayResY) if cfg.FontSize != want { t.Errorf("档位 %d(%dx%d)字号应为 %d,实际 %d", key, cfg.PlayResX, cfg.PlayResY, want, cfg.FontSize) } } } // 主流程:先把坐标系统一到视频分辨率,再按画布定字号 func TestApplyFontSize(t *testing.T) { // 横屏弹幕文件 + 竖屏成片:以前按高度比会放大到 71,现在按短边取 40 meta := danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 60} unifyDanmuSpace(&meta, 720, 1280) applyFontSize(&meta, 0) if meta.FontSize != 40 { t.Errorf("竖屏成片字号应为 40,实际 %d", meta.FontSize) } // 横屏成片和弹幕文件一致时保持原样 meta = danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 60} unifyDanmuSpace(&meta, 1920, 1080) applyFontSize(&meta, 0) if meta.FontSize != 60 { t.Errorf("1080p 横屏字号应为 60,实际 %d", meta.FontSize) } // 竖屏弹幕文件 + 同尺寸竖屏成片:老文件写的 60 要归一到 40 meta = danmuMeta{PlayResX: 720, PlayResY: 1280, FontSize: 60} unifyDanmuSpace(&meta, 720, 1280) applyFontSize(&meta, 0) if meta.FontSize != 40 { t.Errorf("竖屏弹幕文件应归一到 40,实际 %d", meta.FontSize) } // 设置里填了数就按填的来(成片像素) meta = danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 60} unifyDanmuSpace(&meta, 720, 1280) applyFontSize(&meta, 33) if meta.FontSize != 33 { t.Errorf("填了 33 就该用 33,实际 %d", meta.FontSize) } // 拿不到画布尺寸时保留弹幕文件里的字号 meta = danmuMeta{FontSize: 48} applyFontSize(&meta, 0) if meta.FontSize != 48 { t.Errorf("拿不到尺寸时应保留 48,实际 %d", meta.FontSize) } // 低分辨率也别算出 0 号字 meta = danmuMeta{PlayResX: 4, PlayResY: 4, FontSize: 30} applyFontSize(&meta, 0) if meta.FontSize < 1 { t.Errorf("字号不能小于 1,实际 %d", meta.FontSize) } } // 弹幕坐标系(PlayRes)和视频分辨率不一致时统一到视频坐标系; // 字号不在这里改,由 applyFontSize 按最终画布决定 func TestUnifyDanmuSpace(t *testing.T) { cases := []struct { name string resX, resY, fontSize int vw, vh int wantChanged bool }{ {"坐标系一致不动", 1920, 1080, 60, 1920, 1080, false}, {"960x540 弹幕配 720x1280 视频", 960, 540, 30, 720, 1280, true}, {"探测不到视频尺寸就不动", 960, 540, 30, 0, 0, false}, {"相同宽高比但不同分辨率也要按视频走", 1280, 720, 40, 1920, 1080, true}, } for _, c := range cases { meta := danmuMeta{PlayResX: c.resX, PlayResY: c.resY, FontSize: c.fontSize} changed := unifyDanmuSpace(&meta, c.vw, c.vh) if changed != c.wantChanged { t.Errorf("%s:changed 应为 %v,实际 %v", c.name, c.wantChanged, changed) } if meta.FontSize != c.fontSize { t.Errorf("%s:统一坐标系不该动字号,应为 %d,实际 %d", c.name, c.fontSize, meta.FontSize) } if changed { if meta.PlayResX != c.vw || meta.PlayResY != c.vh { t.Errorf("%s:PlayRes 应为 %dx%d,实际 %dx%d", c.name, c.vw, c.vh, meta.PlayResX, meta.PlayResY) } // 统一之后头像尺寸和文字都在视频坐标系里,必须能对上 if avatarSize(meta.FontSize) <= 0 { t.Errorf("%s:头像直径算出来是 %d", c.name, avatarSize(meta.FontSize)) } } else if meta.PlayResX != c.resX || meta.PlayResY != c.resY { t.Errorf("%s:不该改的时候改了 PlayRes", c.name) } } } // 按渲染时的公式统计同弹道追尾条数 func countLaneOverlaps(records []danmuRecord, meta danmuMeta) int { show := int64(danmuShowSeconds * 1e9) resX := int64(meta.PlayResX) last := map[int]int64{} // 弹道 → 上一条的让位时刻 over := 0 for _, r := range records { total := int64(avatarSize(meta.FontSize)+avatarGap(meta.FontSize)) + int64(r.Width) rel := int64(r.Time * 1e9) emerge := rel + total*show/(resX+total) if prev, ok := last[r.Lane]; ok && rel < prev { over++ } last[r.Lane] = emerge } return over } // 密集弹幕(弹道信息全是坏的:lane 都是 0、width 都是 0)重排后不应再有追尾, // 也不该超过弹道行数上限 func TestRelayoutNoOverlapOnDenseInput(t *testing.T) { meta := danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 60} texts := []string{"短", "中等长度的一条弹幕", "这是一条很长的弹幕内容用来占位置", "colong"} var records []danmuRecord t0 := 0.0 for i := 0; i < 600; i++ { records = append(records, danmuRecord{ Time: t0, UID: int64(1000 + i%7), Name: "观众" + string(rune('A'+i%26)), Content: texts[i%len(texts)], Lane: 0, Width: 0, }) t0 += 0.05 + float64(i%5)*0.07 // 0.05 ~ 0.33 秒一条 } relayoutLanes(records, meta) show := int64(danmuShowSeconds * 1e9) resX := int64(meta.PlayResX) type state struct{ emerge int64 } last := map[int]state{} for i, r := range records { if r.Lane < 0 || r.Lane >= laneNum { t.Fatalf("第 %d 条弹道越界:%d", i, r.Lane) } total := int64(avatarSize(meta.FontSize)+avatarGap(meta.FontSize)) + int64(r.Width) if total <= 0 { t.Fatalf("第 %d 条宽度没算出来:%d", i, r.Width) } rel := int64(r.Time * 1e9) emerge := rel + total*show/(resX+total) if prev, ok := last[r.Lane]; ok && rel < prev.emerge { t.Fatalf("第 %d 条和上一条挤在同弹道 %d:出现 %v 早于上一条让位 %v", i, r.Lane, r.Time, float64(prev.emerge)/1e9) } last[r.Lane] = state{emerge} } }