合并前重排弹道并支持自定义弹幕字号
用户反馈两件事:弹幕想再小些;某场合并的 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 干净。
This commit is contained in:
@@ -21,6 +21,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -202,6 +203,14 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 弹幕字号可被设置覆盖(0 = 跟随弹幕文件),头像尺寸随字号等比缩放
|
||||
if config.DanmuFontSize > 0 {
|
||||
lPrintf("弹幕字号按设置使用 %d(弹幕文件里是 %d)", config.DanmuFontSize, meta.FontSize)
|
||||
meta.FontSize = config.DanmuFontSize
|
||||
}
|
||||
// 弹道按最终字号统一重排,避免还原弹幕或改字号后同弹道追尾
|
||||
relayoutLanes(records, meta)
|
||||
|
||||
// 下载并圆形裁切头像
|
||||
avatarPaths := make(map[int64]string)
|
||||
dir := filepath.Dir(jsonlFile)
|
||||
@@ -269,6 +278,57 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
||||
return ctx.burnChunked(chunks)
|
||||
}
|
||||
|
||||
// 渲染前按最终字号重排弹道:jsonl 里的 lane/width 是录制或 .ass 还原时算的,
|
||||
// 从原格式 .ass 还原的弹幕没把昵称和头像算进宽度(占位提前放行了同弹道的下一条,
|
||||
// 画面上就追尾叠字),改过字号后更是全对不上。这里用和录制时相同的碰撞公式、
|
||||
// 按「昵称(uid):内容 + 头像」的完整宽度重新分配一遍,保证弹道不重叠。
|
||||
func relayoutLanes(records []danmuRecord, meta danmuMeta) {
|
||||
sort.SliceStable(records, func(i, j int) bool { return records[i].Time < records[j].Time })
|
||||
fs := meta.FontSize
|
||||
show := int64(danmuShowSeconds * 1e9)
|
||||
resX := int64(meta.PlayResX)
|
||||
avW, avGap := avatarSize(fs), avatarGap(fs)
|
||||
// 空弹道用「远古」状态占位:placeLane 的条件是本条出现时间晚于上一条的让位时刻,
|
||||
// 零值下 0 > 0 不成立,第一条弹幕(还原出来的弹幕时间可能正好是 0)会占不上弹道,
|
||||
// 占用状态丢掉后后面的弹幕又会跟着落回空弹道,等于白排。录制时弹幕时间总大于 0,
|
||||
// 所以原逻辑没暴露这个问题。
|
||||
lanes := make([]laneState, laneNum)
|
||||
for i := range lanes {
|
||||
lanes[i] = laneState{appear: math.MinInt64, emerge: math.MinInt64, disappear: math.MinInt64}
|
||||
}
|
||||
for i := range records {
|
||||
r := &records[i]
|
||||
r.Width = textWidth(speakerText(r.Name, r.UID, r.Content), fs)
|
||||
total := int64(avW+avGap) + int64(r.Width)
|
||||
rel := int64(r.Time * 1e9)
|
||||
dt := laneState{
|
||||
appear: rel,
|
||||
emerge: rel + total*show/(resX+total),
|
||||
disappear: rel + show,
|
||||
}
|
||||
left := rel + resX*show/(resX+total)
|
||||
j := placeLane(lanes, dt, left)
|
||||
if j < 0 {
|
||||
// 100 条弹道都还占着:退到最早让出来的那条。录制时是直接不写这条,
|
||||
// 合并时丢一条等于凭空少一句弹幕,压一点也比丢了强。
|
||||
j = earliestLane(lanes)
|
||||
lanes[j] = dt
|
||||
}
|
||||
r.Lane = j
|
||||
}
|
||||
}
|
||||
|
||||
// 最早会空出来的弹道行
|
||||
func earliestLane(lanes []laneState) int {
|
||||
best := 0
|
||||
for i := range lanes {
|
||||
if lanes[i].disappear < lanes[best].disappear {
|
||||
best = i
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// 用 ffmpeg 探测媒体文件的时长(秒),探测不到返回 0
|
||||
func probeDuration(ffmpegFile, path string) float64 {
|
||||
cmd := exec.Command(ffmpegFile, "-hide_banner", "-i", path)
|
||||
|
||||
Reference in New Issue
Block a user