用户手动合并一场竖屏录播(视频 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 / 分辨率解析的用例。
254 lines
9.6 KiB
Go
254 lines
9.6 KiB
Go
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}
|
||
}
|
||
}
|