From cbe5dfa982a5663840e5337177bf3f43ef0975d6 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Sat, 26 Sep 2026 00:59:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=B9=E5=B9=95=E5=AD=97=E5=8F=B7=E6=8C=89?= =?UTF-8?q?=E7=94=BB=E9=9D=A2=E7=9F=AD=E8=BE=B9=E8=87=AA=E5=8A=A8=E5=AE=9A?= =?UTF-8?q?=EF=BC=8C=E7=AB=96=E5=B1=8F=E6=88=90=E7=89=87=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=AD=97=E5=A4=A7=E5=88=B0=E9=93=BA=E6=BB=A1=E6=95=B4=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 全过。 --- .gitignore | 2 + README.md | 2 +- config.go | 4 +- danmu.go | 3 +- danmuburn.go | 79 +++++++++++++++++++++++-------- doc/webapi.md | 2 +- layout_test.go | 125 ++++++++++++++++++++++++++++++++++++++++++++----- webui.go | 4 +- 8 files changed, 181 insertions(+), 40 deletions(-) diff --git a/.gitignore b/.gitignore index e43cfa9..9b4bed4 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ acfunlive /bin /merged /record +# 头像缓存放在程序所在目录,程序正好跑在仓库里时会落在这里,别提交进去 +/avatar_cache # Binaries for programs and plugins *.exe diff --git a/README.md b/README.md index 543ad78..fe44f83 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/config.go b/config.go index b53bb5c..95b3786 100644 --- a/config.go +++ b/config.go @@ -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 { diff --git a/danmu.go b/danmu.go index 5271213..63e3fb2 100644 --- a/danmu.go +++ b/danmu.go @@ -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}, diff --git a/danmuburn.go b/danmuburn.go index fc81012..f23de40 100644 --- a/danmuburn.go +++ b/danmuburn.go @@ -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 } diff --git a/doc/webapi.md b/doc/webapi.md index ae077f3..b5cf6d0 100644 --- a/doc/webapi.md +++ b/doc/webapi.md @@ -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`,日志里会打印实际用的那一个。 diff --git a/layout_test.go b/layout_test.go index dc4df76..23bf57b 100644 --- a/layout_test.go +++ b/layout_test.go @@ -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 { diff --git a/webui.go b/webui.go index a421198..e8be0ae 100644 --- a/webui.go +++ b/webui.go @@ -317,7 +317,7 @@ const webuiHTML = `
-
+
文本框改完按回车或点右上角「保存设置」生效,不做这一步的话 3 秒后会被刷回原值。
@@ -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 {