Files
acfunlive-remake/layout_test.go
T
tamakyi 9c94a72c4d 合并前重排弹道并支持自定义弹幕字号
用户反馈两件事:弹幕想再小些;某场合并的 1:19:25 处弹幕糊在一起(把成片抽帧
出来一看,是同一弹道上两条弹幕追尾叠字)。

重叠的根因:弹幕从上游原版的 .ass 还原成 jsonl 时,lane 是按「只有内容」的
宽度反推的(原格式的 x/y 就是这么摆的),而渲染用的是「昵称(uid):内容 + 头像」
的完整宽度 —— 后者占位时间长得多,照搬原弹道必然追尾。用用户那份 3011 条的真实
jsonl 验过:按渲染公式统计,重排前 960 条(31.9%)追尾,重排后 0 条。

- 新增 relayoutLanes:渲染前按最终字号统一重排弹道并重算宽度,公式和录制时的
  placeLane 完全一致,所以录制下来的 jsonl 重排后弹道不变(已加用例覆盖)。
- 空弹道用 math.MinInt64 占位:placeLane 的条件是「本条出现时间晚于上一条的让位
  时刻」,零值下 0 > 0 不成立,第一条弹幕(还原出来的时间可能正好是 0)会占不上
  弹道、占用状态丢失,后面又跟着乱排。录制的弹幕时间总大于 0,所以原逻辑没暴露。
- 弹道全满(100 条都占着)时退到最早让出来的那条:录制时是直接不写这条,合并时
  丢一条等于凭空少一句弹幕,压一点也比丢了强。
- 新增 danmuFontSize 设置(合并分区,0 = 跟随弹幕文件):覆盖渲染字号,头像按
  同一比例缩放;改了字号会在重排时一并生效,不会因为宽度变了又追尾。

验证:layout_test.go 5 个用例;用新 ffmpeg(用户已自行升级到 n9.0.2)在隔离实例上
跑了真合并 —— 字号 95 以上的对照(60px → 42px:抽帧量到的弹幕墨迹高度 48px → 36px)、
按 1:19:00 裁出的 130 秒片段合并前后追尾 25 条 → 0 条;重跑 verify_settings.py
(新增第 8 节覆盖字号字段)、verify_pick.py、verify_streamer_br.py 无回归;
go test ./...、gofmt、go vet 干净。
2026-09-25 18:18:30 +08:00

131 lines
4.8 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 "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)
}
}
// 密集弹幕(弹道信息全是坏的: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}
}
}