用户反馈两件事:弹幕想再小些;某场合并的 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 干净。
131 lines
4.8 KiB
Go
131 lines
4.8 KiB
Go
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}
|
||
}
|
||
}
|