Files
acfunlive-remake/layout_test.go
T
tamakyi e8b455dfe8 合并时把弹幕坐标系统一到视频分辨率;修「改设置会删掉正在合并的临时文件」
用户手动合并一场竖屏录播(视频 720x1280,弹幕文件 960x540、字号 30)时发现:
字号改大以后文字变大了,但头像还是小小的,而且越往下的弹幕头像和文字错得越远。

原因:弹幕文件里的 PlayResX/PlayResY 是录制当时的画面尺寸,和现在的视频对不上时,
libass 会按「视频高 ÷ PlayResY」缩放文字和它的坐标(这里 1280/540 ≈ 2.37),
而头像叠加用的是视频像素坐标(头像直径 = 字号×4/5、行位置 = 字号×弹道号),
两者不在同一个坐标系里。实测同一帧(720x1280 竖屏):

  修复前:文字墨迹高 54px、头像直径 24px,第 4 行文字在 y=226 而它的头像在 y=92
  修复后:文字墨迹高 54px、头像直径 56px,头像与对应文字行一一对齐

做法是合并前探测视频分辨率,不一致就把 PlayRes 改成视频宽高:跟随弹幕文件时字号
按高度比换算(占画面的比例不变),用户填了 danmuFontSize 就按成片像素直接用 ——
因为用户看到的本来就是成片上的像素。只在有视频时做,透明头像层模式不变;
探测不到分辨率就保持原样。坐标系一致时(1920x1080 配 1920x1080)完全不改动,
所以正常录播的合并结果和以前一样。验证方式是拿一份「原生就对」的弹幕文件做对照,
自动统一后的成片与它在文字行位置、头像直径和位置上逐项相同。

顺带修一个真 bug(上一轮引入):checkConfig() 里清了合成临时文件,而改设置会重新
读取配置并调用 checkConfig(),于是合并途中改任何设置都会把正在用的分块字幕/临时
成片删掉,合并当场失败(日志:清理上次留下的临时文件 acfunlive_chunk_single.ass
→ Unable to open ...)。清理挪到只在启动时执行的 cleanLeftoverTempFiles()。

新增 config_test.go(改设置不能删掉在用的临时文件,反向验证过:把清理塞回
checkConfig 会让它失败)和 unifyDanmuSpace / 分辨率解析的用例。
2026-09-25 19:03:21 +08:00

254 lines
9.6 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 (
"strconv"
"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)
}
}
// 弹幕坐标系(PlayRes)和视频分辨率不一致时统一到视频坐标系:
// 跟随文件时字号按高度比换算,用户填了字号就按成片像素直接用
func TestUnifyDanmuSpace(t *testing.T) {
cases := []struct {
name string
resX, resY, fontSize int
vw, vh int
override int
wantChanged bool
wantFontSize int
}{
{"坐标系一致不动", 1920, 1080, 60, 1920, 1080, 0, false, 60},
{"960x540 弹幕配 720x1280 视频(跟随文件)", 960, 540, 30, 720, 1280, 0, true, 71},
{"同上但用户填了 42", 960, 540, 30, 720, 1280, 42, true, 42},
{"手机档 720x1280 弹幕配 540x960 视频", 720, 1280, 60, 540, 960, 0, true, 45},
{"探测不到视频尺寸就不动", 960, 540, 30, 0, 0, 0, false, 30},
{"相同宽高比但不同分辨率也要按视频走", 1280, 720, 40, 1920, 1080, 0, true, 60},
}
for _, c := range cases {
meta := danmuMeta{PlayResX: c.resX, PlayResY: c.resY, FontSize: c.fontSize}
changed := unifyDanmuSpace(&meta, c.vw, c.vh, c.override)
if changed != c.wantChanged {
t.Errorf("%s:changed 应为 %v,实际 %v", c.name, c.wantChanged, changed)
}
if meta.FontSize != c.wantFontSize {
t.Errorf("%s:字号应为 %d,实际 %d", c.name, c.wantFontSize, 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)
}
}
}
// ffmpeg 输出的解析:分辨率要靠「逗号 + 数字x数字」,别被编码器名里的 0x… 骗到
func TestProbeVideoSizeRegex(t *testing.T) {
cases := []struct {
line string
w, h int
}{
{" Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(tv, bt709, progressive), 1920x1080 [SAR 1:1 DAR 16:9], 1500 kb/s, 23.98 fps", 1920, 1080},
{" Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(tv, smpte170m/unknown/unknown, progressive), 720x1280 [SAR 1:1 DAR 9:16], 1570 kb/s, 10 fps", 720, 1280},
{" Stream #0:0: Video: hevc (Main) (hvc1 / 0x31637668), yuv420p(tv), 3840x2160, 8000 kb/s", 3840, 2160},
{" Stream #0:0(und): Video: h264 (High), yuv420p, 640x480, 30 fps", 640, 480},
}
for _, c := range cases {
m := videoSizeRe.FindStringSubmatch(c.line)
if len(m) != 3 {
t.Errorf("没解析出行:%s", c.line)
continue
}
w, _ := strconv.Atoi(m[1])
h, _ := strconv.Atoi(m[2])
if w != c.w || h != c.h {
t.Errorf("解析成 %dx%d,应为 %dx%d:%s", w, h, c.w, c.h, c.line)
}
}
}
// 按渲染时的公式统计同弹道追尾条数
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}
}
}