合并时把弹幕坐标系统一到视频分辨率;修「改设置会删掉正在合并的临时文件」

用户手动合并一场竖屏录播(视频 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 / 分辨率解析的用例。
This commit is contained in:
tamakyi
2026-09-25 19:03:21 +08:00
parent e44dfa3a4a
commit e8b455dfe8
6 changed files with 205 additions and 4 deletions
+70 -1
View File
@@ -1,6 +1,9 @@
package main
import "testing"
import (
"strconv"
"testing"
)
// 还原自 .ass 的弹幕:原格式的弹道是按「只有内容」的宽度占位的,渲染用的是
// 「昵称(uid):内容 + 头像」的宽度,占位更长 → 同弹道会追尾。重排后必须分开。
@@ -123,6 +126,72 @@ func TestRelayoutFixesConcatenatedSegmentConflict(t *testing.T) {
}
}
// 弹幕坐标系(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)