弹幕字号按画面短边自动定,竖屏成片不再字大到铺满整行

1. 字号基准从「画面高度」改成「画面短边」
   以前合并时只用高度比换算字号(字号 × 视频高 ÷ 弹幕文件高)。横屏弹幕文件
   (1920x1080、60 号)配竖屏视频(720x1280)时会被放大到 71 号,而画面只有
   720 宽,一条 10 字弹幕就铺满整行。横竖屏互换时高度不是约束文字宽度的维度,
   短边才是:横屏取高、竖屏取宽。

2. 「弹幕字号」填 0 的含义从「跟随弹幕文件」改成「自动按画面分辨率」
   取画布短边的 5.56%(60/1080)。于是 1920x1080 → 60、1280x720 → 40、
   960x540 → 30,横屏行为与以前完全一致;720x1280 → 40(以前 60)、
   640x1280 → 36。填大于 0 仍是精确像素覆盖。

3. 录制端竖屏档位预设 720x1280 的字号从 60 改成 40
   原来横屏三档都是短边的 5.56%,只有竖屏这档是 8.33%,比横屏大 50%。
   改完四档口径一致,新录的弹幕文件和成片也对得上。

4. 坐标系和字号拆开
   unifyDanmuSpace 只负责把 PlayRes 统一到视频分辨率,字号统一交给
   applyFontSize 按最终画布算,避免两处各改一次。

5. .gitignore 加上 /avatar_cache
   头像缓存挪到程序目录后,程序正好跑在仓库里,缓存目录会被 git 看见,
   git add . 会把那 336 个 PNG 一起提交进去。

已验证:
- 新增 TestShortSide / TestAutoFontSize / TestSubConfigsMatchAutoFontSize /
  TestApplyFontSize;TestUnifyDanmuSpace 改成只校验 PlayRes,并且新增断言
  确认它不再动字号。
- 反向验证:把短边规则临时退回「按高度」,TestAutoFontSize 报
  「720x1280 应为 40,实际 71」「1080x1920 应为 60,实际 107」,
  TestApplyFontSize、TestSubConfigsMatchAutoFontSize 同步失败。
- 端到端:造 720x1280 竖屏视频 + 1920x1080/60 号的 jsonl,字号填 0 真跑一次合并,
  日志打出「竖屏画布 720x1280,弹幕字号自动取 40」;同一输入再用旧规则跑一次对照,
  抽同一时刻的帧量墨迹高度:改前 46px、改后 25px(1.84 倍)。
- gofmt / go vet / go build / GOOS=linux go build / go test 全过。
This commit is contained in:
tamakyi
2026-09-26 00:59:17 +08:00
parent aa47dd342d
commit cbe5dfa982
8 changed files with 181 additions and 40 deletions
+2
View File
@@ -9,6 +9,8 @@ acfunlive
/bin
/merged
/record
# 头像缓存放在程序所在目录,程序正好跑在仓库里时会落在这里,别提交进去
/avatar_cache
# Binaries for programs and plugins
*.exe
+1 -1
View File
@@ -83,7 +83,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
"mergeDelDanmu": false, // 合并完成后删除弹幕文件(ass、nick.ass、jsonl)
"mergeOutputDir": "", // 合并完成后成片移动到该文件夹,留空则不移动
"mergeBitrate": 0, // 合并成片的目标码率(Kbps),0 表示按「源片码率 + 500」自动计算
"danmuFontSize": 0, // 合并时弹幕文字的字号(像素),0 表示跟随弹幕文件;头像随字号等比缩放
"danmuFontSize": 0, // 合并时弹幕文字的字号(像素),0 表示按画面分辨率自动(短边 × 5.56%,横屏取高、竖屏取宽);头像随字号等比缩放
"defaultQualityPref": "second", // 新添加主播的默认档位偏好,取值同上面的 qualityPref
"defaultBitrate": 0, // 只在上面为 custom 时有意义:默认的自定义码率上限(Kbps)
+2 -2
View File
@@ -137,7 +137,7 @@ type configData struct {
MergeDelDanmu bool `json:"mergeDelDanmu"` // 合并完成后删除弹幕文件
MergeOutputDir string `json:"mergeOutputDir"` // 合并完成后成片移动到该文件夹,留空不移动
MergeBitrate int `json:"mergeBitrate"` // 合并成片的目标码率(Kbps),0 表示使用默认质量
DanmuFontSize int `json:"danmuFontSize"` // 合并时弹幕文字的字号(头像随字号等比缩放),0 表示跟随弹幕文件
DanmuFontSize int `json:"danmuFontSize"` // 合并时弹幕文字的字号(像素,头像随字号等比缩放),0 表示按画面分辨率自动
// 新添加主播的默认录制档位(留空/非法按第二高)与自定义时的码率上限
DefaultQualityPref string `json:"defaultQualityPref,omitempty"`
@@ -419,7 +419,7 @@ func checkConfig() {
os.Exit(1)
}
if config.DanmuFontSize < 0 {
lPrintErrf("%s里的danmuFontSize不能小于 0(0 表示跟随弹幕文件)", configFile)
lPrintErrf("%s里的danmuFontSize不能小于 0(0 表示按画面分辨率自动)", configFile)
os.Exit(1)
}
if config.DefaultBitrate < 0 {
+2 -1
View File
@@ -16,8 +16,9 @@ import (
)
// 不同的视频分辨率对应的弹幕字幕设置
// 字号一律取「短边 × 60/1080」(横屏取高、竖屏取宽),和合并端的自动字号一致
var subConfigs = map[int]acfundanmu.SubConfig{
0: {PlayResX: 720, PlayResY: 1280, FontSize: 60}, // 这是手机直播,有一些是 540X960
0: {PlayResX: 720, PlayResY: 1280, FontSize: 40}, // 这是手机直播,有一些是 540X960
540: {PlayResX: 960, PlayResY: 540, FontSize: 30},
720: {PlayResX: 1280, PlayResY: 720, FontSize: 40},
1080: {PlayResX: 1920, PlayResY: 1080, FontSize: 60},
+59 -20
View File
@@ -94,6 +94,58 @@ func avatarGap(fontSize int) int {
return fontSize / 6
}
// 画布短边:横屏取高、竖屏取宽。弹幕文字和头像的尺寸都以它为准 ——
// 竖屏画面窄,按高度算出来的字会大到一条弹幕就铺满整行。
func shortSide(w, h int) int {
if w <= 0 || h <= 0 {
return 0
}
if w < h {
return w
}
return h
}
// 自动字号的比例:短边 × 60/1080 ≈ 5.56%,
// 和录制端的档位预设一致(1920x1080 → 60、1280x720 → 40、960x540 → 30)
const autoFontRatioNum, autoFontRatioDen = 60, 1080
// 按画布短边算自动字号,拿不到尺寸时返回 0
func autoFontSize(w, h int) int {
short := shortSide(w, h)
if short <= 0 {
return 0
}
fs := int(math.Round(float64(short) * autoFontRatioNum / autoFontRatioDen))
if fs < 1 {
fs = 1
}
return fs
}
// 定成片字号。设置里填了 >0 就按填的(成片像素);填 0(默认)按画布短边自动算,
// 这样横屏和竖屏的字看起来一样大,竖屏成片不会字大到铺满整行。头像随字号等比缩放。
func applyFontSize(meta *danmuMeta, override int) {
if override > 0 {
if override != meta.FontSize {
lPrintf("弹幕字号按设置使用 %d(弹幕文件里是 %d)", override, meta.FontSize)
}
meta.FontSize = override
return
}
auto := autoFontSize(meta.PlayResX, meta.PlayResY)
if auto <= 0 || auto == meta.FontSize {
return
}
orient := "横屏"
if meta.PlayResY > meta.PlayResX {
orient = "竖屏"
}
lPrintf("%s画布 %dx%d,弹幕字号自动取 %d(短边 × %d/%d,弹幕文件里是 %d)",
orient, meta.PlayResX, meta.PlayResY, auto, autoFontRatioNum, autoFontRatioDen, meta.FontSize)
meta.FontSize = auto
}
// 弹幕合成入口(命令行)
// 弹幕文件可以给 .jsonl,也可以给 .ass(先还原成 jsonl 再合成)
func runDanmuburn(danmuFile, videoFile, outFile string, offset float64) {
@@ -212,20 +264,17 @@ 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
}
// 弹幕文件的坐标系和视频对不上时,统一到视频坐标系(见 unifyDanmuSpace);
// 顺便拿到源片码率,用于「合成码率填 0」时的自动码率
sourceKbps := 0
if burnMode {
var vw, vh int
vw, vh, sourceKbps = probeVideoStream(ffmpegFile, videoFile)
unifyDanmuSpace(&meta, vw, vh, config.DanmuFontSize)
unifyDanmuSpace(&meta, vw, vh)
lPrintf("视频编码器优先顺序:%s", strings.Join(availableEncoders(ffmpegFile), " → "))
}
// 字号在坐标系统一之后再定,此时 meta.PlayRes 就是成片画布
applyFontSize(&meta, config.DanmuFontSize)
// 弹道按最终字号统一重排,避免还原弹幕或改字号后同弹道追尾
relayoutLanes(records, meta)
@@ -365,24 +414,14 @@ func earliestLane(lanes []laneState) int {
// (例如 960x540 的弹幕文件配 720x1280 的竖屏视频)。libass 会按「视频高 / PlayResY」
// 缩放文字和它的坐标,而头像叠加用的是视频像素坐标,两者不在同一个坐标系里 ——
// 字号越大越明显:头像显得小,而且每往下一行错得越多(看起来和文字分开了)。
// 这里把 PlayRes 改成视频宽高:跟随文件时字号按高度比换算(保持占画面的比例),
// 用户显式填了字号就直接用(他看的是成片上的像素)。返回是否做过调整。
func unifyDanmuSpace(meta *danmuMeta, vw, vh, fontSizeOverride int) bool {
// 这里只负责改 PlayRes,字号交给 applyFontSize 按最终画布重算。返回是否做过调整。
func unifyDanmuSpace(meta *danmuMeta, vw, vh int) bool {
if vw <= 0 || vh <= 0 || (vw == meta.PlayResX && vh == meta.PlayResY) {
return false
}
oldX, oldY, oldFs := meta.PlayResX, meta.PlayResY, meta.FontSize
if fontSizeOverride > 0 {
meta.FontSize = fontSizeOverride
} else {
meta.FontSize = int(math.Round(float64(oldFs) * float64(vh) / float64(oldY)))
}
if meta.FontSize < 1 {
meta.FontSize = 1
}
oldX, oldY := meta.PlayResX, meta.PlayResY
meta.PlayResX, meta.PlayResY = vw, vh
lPrintf("弹幕文件的坐标系是 %dx%d(字号 %d),视频是 %dx%d,改按视频坐标系渲染,字号 %d",
oldX, oldY, oldFs, vw, vh, meta.FontSize)
lPrintf("弹幕文件的坐标系是 %dx%d,视频是 %dx%d,改按视频坐标系渲染", oldX, oldY, vw, vh)
return true
}
+1 -1
View File
@@ -103,7 +103,7 @@ web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全
合并状态通过`GET /api/status`查看:`mergeBusy`(是否在跑)、`mergeOutName`(当前成片名)、`mergeErr`(失败原因)、`mergeDoneAt`(上次完成时间)。合并期间成片会出现在`GET /api/files`里且`inUse`为 true,此时`POST /api/file/remove`会返回 409 拒绝删除。
`POST /api/config`里的`danmuFontSize`控制合并时弹幕文字的字号(像素),头像按同一比例缩放,填 0 表示沿用弹幕文件里记的字号。渲染前会按最终字号重排弹道:从上游原版`.ass`还原出来的弹幕只按内容宽度占位,而渲染用的是「昵称(uid):内容 + 头像」的完整宽度,占位时间更长,照搬原弹道会追尾叠字;重排按完整宽度重新分配,同一条弹道上不会再有两条弹幕叠在一起。
`POST /api/config`里的`danmuFontSize`控制合并时弹幕文字的字号(像素),头像按同一比例缩放,填 0 表示按画面分辨率自动:字号取画面**短边**的 5.56%(横屏取高、竖屏取宽),所以 1920x1080 是 60、1280x720 是 40、720x1280 也是 40 —— 竖屏画面窄,若按高度算会得到 71,一条弹幕就铺满整行。显式填写则按填的像素值直接用。渲染前会按最终字号重排弹道:从上游原版`.ass`还原出来的弹幕只按内容宽度占位,而渲染用的是「昵称(uid):内容 + 头像」的完整宽度,占位时间更长,照搬原弹道会追尾叠字;重排按完整宽度重新分配,同一条弹道上不会再有两条弹幕叠在一起。
`mergeBitrate`填 0(默认)时按「源片码率 + 500 Kbps」自动计算:先探测视频流的码率,加 500 留给烧进去的弹幕;探测不到就退回质量模式。填大于 0 的值则按该码率固定输出。编码器优先硬件 HEVC(`hevc_qsv`,同画质比 H.264 省约三成体积,mp4 里打`hvc1`标记),不支持时回退`h264_qsv`、`libx264`,日志里会打印实际用的那一个。
+112 -13
View File
@@ -123,32 +123,131 @@ func TestRelayoutFixesConcatenatedSegmentConflict(t *testing.T) {
}
}
// 弹幕坐标系(PlayRes)和视频分辨率不一致时统一到视频坐标系:
// 跟随文件时字号按高度比换算,用户填了字号就按成片像素直接用
// 画布短边:横屏取高、竖屏取宽
func TestShortSide(t *testing.T) {
cases := []struct {
w, h, want int
}{
{1920, 1080, 1080},
{1080, 1920, 1080},
{720, 1280, 720},
{1280, 720, 720},
{1080, 1080, 1080},
{0, 1080, 0},
{1920, -1, 0},
}
for _, c := range cases {
if got := shortSide(c.w, c.h); got != c.want {
t.Errorf("shortSide(%d,%d) 应为 %d,实际 %d", c.w, c.h, c.want, got)
}
}
}
// 自动字号 = 画布短边的 5.56%。竖屏必须按宽度算 ——
// 按高度算的话 720x1280 会得到 71,一条弹幕就铺满整行。
func TestAutoFontSize(t *testing.T) {
cases := []struct {
vw, vh, want int
}{
{1920, 1080, 60},
{1280, 720, 40},
{960, 540, 30},
{720, 1280, 40},
{640, 1280, 36},
{1080, 1920, 60},
{1080, 1080, 60}, // 方形按宽算
{0, 1080, 0}, // 拿不到尺寸
{1920, 0, 0},
}
for _, c := range cases {
if got := autoFontSize(c.vw, c.vh); got != c.want {
t.Errorf("autoFontSize(%d,%d) 应为 %d,实际 %d", c.vw, c.vh, c.want, got)
}
}
}
// 录制端的档位预设必须和自动字号规则一致,否则录出来的弹幕文件和成片对不上
func TestSubConfigsMatchAutoFontSize(t *testing.T) {
for key, cfg := range subConfigs {
want := autoFontSize(cfg.PlayResX, cfg.PlayResY)
if cfg.FontSize != want {
t.Errorf("档位 %d(%dx%d)字号应为 %d,实际 %d",
key, cfg.PlayResX, cfg.PlayResY, want, cfg.FontSize)
}
}
}
// 主流程:先把坐标系统一到视频分辨率,再按画布定字号
func TestApplyFontSize(t *testing.T) {
// 横屏弹幕文件 + 竖屏成片:以前按高度比会放大到 71,现在按短边取 40
meta := danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 60}
unifyDanmuSpace(&meta, 720, 1280)
applyFontSize(&meta, 0)
if meta.FontSize != 40 {
t.Errorf("竖屏成片字号应为 40,实际 %d", meta.FontSize)
}
// 横屏成片和弹幕文件一致时保持原样
meta = danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 60}
unifyDanmuSpace(&meta, 1920, 1080)
applyFontSize(&meta, 0)
if meta.FontSize != 60 {
t.Errorf("1080p 横屏字号应为 60,实际 %d", meta.FontSize)
}
// 竖屏弹幕文件 + 同尺寸竖屏成片:老文件写的 60 要归一到 40
meta = danmuMeta{PlayResX: 720, PlayResY: 1280, FontSize: 60}
unifyDanmuSpace(&meta, 720, 1280)
applyFontSize(&meta, 0)
if meta.FontSize != 40 {
t.Errorf("竖屏弹幕文件应归一到 40,实际 %d", meta.FontSize)
}
// 设置里填了数就按填的来(成片像素)
meta = danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 60}
unifyDanmuSpace(&meta, 720, 1280)
applyFontSize(&meta, 33)
if meta.FontSize != 33 {
t.Errorf("填了 33 就该用 33,实际 %d", meta.FontSize)
}
// 拿不到画布尺寸时保留弹幕文件里的字号
meta = danmuMeta{FontSize: 48}
applyFontSize(&meta, 0)
if meta.FontSize != 48 {
t.Errorf("拿不到尺寸时应保留 48,实际 %d", meta.FontSize)
}
// 低分辨率也别算出 0 号字
meta = danmuMeta{PlayResX: 4, PlayResY: 4, FontSize: 30}
applyFontSize(&meta, 0)
if meta.FontSize < 1 {
t.Errorf("字号不能小于 1,实际 %d", meta.FontSize)
}
}
// 弹幕坐标系(PlayRes)和视频分辨率不一致时统一到视频坐标系;
// 字号不在这里改,由 applyFontSize 按最终画布决定
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},
{"坐标系一致不动", 1920, 1080, 60, 1920, 1080, false},
{"960x540 弹幕配 720x1280 视频", 960, 540, 30, 720, 1280, true},
{"探测不到视频尺寸就不动", 960, 540, 30, 0, 0, false},
{"相同宽高比但不同分辨率也要按视频走", 1280, 720, 40, 1920, 1080, true},
}
for _, c := range cases {
meta := danmuMeta{PlayResX: c.resX, PlayResY: c.resY, FontSize: c.fontSize}
changed := unifyDanmuSpace(&meta, c.vw, c.vh, c.override)
changed := unifyDanmuSpace(&meta, c.vw, c.vh)
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 meta.FontSize != c.fontSize {
t.Errorf("%s:统一坐标系不该动字号,应为 %d,实际 %d", c.name, c.fontSize, meta.FontSize)
}
if changed {
if meta.PlayResX != c.vw || meta.PlayResY != c.vh {
+2 -2
View File
@@ -317,7 +317,7 @@ const webuiHTML = `<!DOCTYPE html>
</div>
</div>
<div class="f"><label>合成码率(Kbps,0 = 源片码率 + 500)</label><input type="text" id="mergeBitrate" inputmode="numeric" placeholder="例如 1500,填 0 自动"></div>
<div class="f"><label>弹幕字号(像素,0 = 跟随弹幕文件)</label><input type="text" id="danmuFontSize" inputmode="numeric" placeholder="例如 42,头像随字号一起缩放"></div>
<div class="f"><label>弹幕字号(像素,0 = 自动按画面分辨率)</label><input type="text" id="danmuFontSize" inputmode="numeric" placeholder="0 自动,或填例如 42;头像随字号一起缩放"></div>
</div>
<div class="hint">文本框改完按回车或点右上角「保存设置」生效,不做这一步的话 3 秒后会被刷回原值。</div>
</div>
@@ -1641,7 +1641,7 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
return
}
if req.DanmuFontSize < 0 {
http.Error(w, "danmuFontSize 不能小于 0(0 表示跟随弹幕文件)", http.StatusBadRequest)
http.Error(w, "danmuFontSize 不能小于 0(0 表示按画面分辨率自动)", http.StatusBadRequest)
return
}
if req.DefaultBitrate < 0 {