Files
tamakyi cbe5dfa982 弹幕字号按画面短边自动定,竖屏成片不再字大到铺满整行
1. 字号基准从「画面高度」改成「画面短边」
   以前合并时只用高度比换算字号(字号 × 视频高 ÷ 弹幕文件高)。横屏弹幕文件
   (1920x1080、60 号)配竖屏视频(720x1280)时会被放大到 71 号,而画面只有
   720 宽,一条 10 字弹幕就铺满整行。横竖屏互换时高度不是约束文字宽度的维度,
   短边才是:横屏取高、竖屏取宽。

2. 「弹幕字号」填 0 的含义从「跟随弹幕文件」改成「自动按画面分辨率」
   取画布短边的 5.56%(60/1080)。于是 1920x1080 → 60、1280x720 → 40、
   960x540 → 30,横屏行为与以前完全一致;720x1280 → 40(以前 60)、
   640x1280 → 36。填大于 0 仍是精确像素覆盖。

3. 录制端竖屏档位预设 720x1280 的字号从 60 改成 40
   原来横屏三档都是短边的 5.56%,只有竖屏这档是 8.33%,比横屏大 50%。
   改完四档口径一致,新录的弹幕文件和成片也对得上。

4. 坐标系和字号拆开
   unifyDanmuSpace 只负责把 PlayRes 统一到视频分辨率,字号统一交给
   applyFontSize 按最终画布算,避免两处各改一次。

5. .gitignore 加上 /avatar_cache
   头像缓存挪到程序目录后,程序正好跑在仓库里,缓存目录会被 git 看见,
   git add . 会把那 336 个 PNG 一起提交进去。

已验证:
- 新增 TestShortSide / TestAutoFontSize / TestSubConfigsMatchAutoFontSize /
  TestApplyFontSize;TestUnifyDanmuSpace 改成只校验 PlayRes,并且新增断言
  确认它不再动字号。
- 反向验证:把短边规则临时退回「按高度」,TestAutoFontSize 报
  「720x1280 应为 40,实际 71」「1080x1920 应为 60,实际 107」,
  TestApplyFontSize、TestSubConfigsMatchAutoFontSize 同步失败。
- 端到端:造 720x1280 竖屏视频 + 1920x1080/60 号的 jsonl,字号填 0 真跑一次合并,
  日志打出「竖屏画布 720x1280,弹幕字号自动取 40」;同一输入再用旧规则跑一次对照,
  抽同一时刻的帧量墨迹高度:改前 46px、改后 25px(1.84 倍)。
- gofmt / go vet / go build / GOOS=linux go build / go test 全过。
2026-09-26 00:59:17 +08:00

325 lines
11 KiB
Go
Raw Permalink 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 "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}
}
}