From e8b455dfe80f30b5fe70ef0d7b66c874d398bb51 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Fri, 25 Sep 2026 19:03:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=88=E5=B9=B6=E6=97=B6=E6=8A=8A=E5=BC=B9?= =?UTF-8?q?=E5=B9=95=E5=9D=90=E6=A0=87=E7=B3=BB=E7=BB=9F=E4=B8=80=E5=88=B0?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E5=88=86=E8=BE=A8=E7=8E=87=EF=BC=9B=E4=BF=AE?= =?UTF-8?q?=E3=80=8C=E6=94=B9=E8=AE=BE=E7=BD=AE=E4=BC=9A=E5=88=A0=E6=8E=89?= =?UTF-8?q?=E6=AD=A3=E5=9C=A8=E5=90=88=E5=B9=B6=E7=9A=84=E4=B8=B4=E6=97=B6?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户手动合并一场竖屏录播(视频 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 / 分辨率解析的用例。 --- README.md | 2 ++ config.go | 12 ++++++--- config_test.go | 65 +++++++++++++++++++++++++++++++++++++++++++++ danmuburn.go | 57 ++++++++++++++++++++++++++++++++++++++++ layout_test.go | 71 +++++++++++++++++++++++++++++++++++++++++++++++++- main.go | 2 ++ 6 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 config_test.go diff --git a/README.md b/README.md index 489483f..55d84d8 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,8 @@ acfunlive -danmuburn xxx.jsonl -video xxx.flv -ffmpeg D:\tools\ffmpeg.exe 弹幕字号默认沿用弹幕文件里记的字号(1080p 下通常是 48~60),嫌大可以调`弹幕字号`,头像会跟着等比缩小。合并前会按最终字号把弹幕重新排一遍弹道:从上游原版的`.ass`还原出来的弹幕,原本是按「只有内容」的宽度占位的,而渲染用的是「昵称(uid):内容 + 头像」的宽度,占位更久,照搬原弹道会追尾叠字——重排按完整宽度重新分配,同一条弹道上不会再有两条弹幕叠在一起。 +弹幕文件里的坐标系(`PlayResX`/`PlayResY`)记的也是录制当时的画面尺寸,老文件常和现在的视频对不上(例如 960x540 的弹幕文件配 720x1280 的竖屏视频)。这种情况下字幕渲染器会按「视频高 ÷ 弹幕高」缩放文字和坐标,而头像叠加用的是视频像素坐标,两边不在一个坐标系里:头像显得小,而且越往下的弹幕错得越多,看起来像和文字分开了。合并时会先探测视频分辨率,不一致就把坐标系统一到视频上(字号按高度比换算,跟画面的相对大小不变;`弹幕字号`填了值就按成片像素直接用),头像和文字因此始终对得上。 + 停止录播时会向 ffmpeg 发送正常退出的指令,让它把文件正常收尾(直接杀进程会在文件尾部留下损坏的数据包,mp4 甚至会因为缺少 moov atom 而整个读不出来)。 `autoMerge`、`mergeDelVideo`、`mergeDelDanmu`三项互不影响,可以同时开启:第一项决定是否自动合并,后两项是合并成功后的清理动作。 diff --git a/config.go b/config.go index d81a0b0..87ca6cc 100644 --- a/config.go +++ b/config.go @@ -411,9 +411,6 @@ func checkConfig() { os.Exit(1) } } - // 上次在合成途中被关掉的话,录播目录和成片目录里会留下临时文件 - cleanTempFiles(recordSaveDir()) - cleanTempFiles(mergeSaveDir()) for _, u := range config.UIDs { if u.Bitrate < 0 { lPrintErrf("%s里的uid %d的bitrate不能小于 0", configFile, u.UID) @@ -422,6 +419,15 @@ func checkConfig() { } } +// 清掉上次运行残留的合成临时文件,只在启动时调用一次。 +// 不能放进 loadConfig:改设置(web UI 或直接改 config.json)会重新读取配置, +// 那样会把正在进行中的合并的临时文件(分块字幕、分块视频、先本地合成的成片) +// 一起删掉,合并当场失败。 +func cleanLeftoverTempFiles() { + cleanTempFiles(recordSaveDir()) + cleanTempFiles(mergeSaveDir()) +} + // 根据 config.UIDs 构建 streamers.crt,调用方需要自己处理 streamers 的锁 func buildStreamers() { news := make(map[int]streamer, len(config.UIDs)) diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..701b3fb --- /dev/null +++ b/config_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// 改设置会重新读取配置(web UI 保存、或直接改 config.json), +// 这一步绝不能删掉正在进行的合并的临时文件 —— 否则合并会当场失败。 +func TestReloadConfigKeepsMergeTempFiles(t *testing.T) { + dir := t.TempDir() + savedConfig := config + savedLocation := configFileLocation + oldDir := configDir + if configDir == nil { + configDir = new(string) + } + defer func() { + config = savedConfig + configFileLocation = savedLocation + configDir = oldDir + }() + + *configDir = dir + configFileLocation = filepath.Join(dir, configFile) + + recDir := filepath.Join(dir, defaultRecordSubdir) + if err := os.MkdirAll(recDir, 0755); err != nil { + t.Fatal(err) + } + // 假装一个合并正在跑:录播目录里有分块字幕和先本地合成的成片 + inUse := []string{ + filepath.Join(recDir, chunkTempPrefix+"single.ass"), + filepath.Join(recDir, chunkTempPrefix+"merging_x.mp4"), + } + for _, p := range inUse { + if err := os.WriteFile(p, []byte("x"), 0644); err != nil { + t.Fatal(err) + } + } + + content, _ := json.Marshal(map[string]string{"directory": filepath.ToSlash(dir)}) + if err := os.WriteFile(configFileLocation, content, 0644); err != nil { + t.Fatal(err) + } + // 重新读取配置的真实路径:loadNewConfig() 就是 loadConfig() + checkConfig() + loadConfig() + checkConfig() + + for _, p := range inUse { + if _, err := os.Stat(p); err != nil { + t.Errorf("重新读取配置把正在用的临时文件删了:%s", filepath.Base(p)) + } + } + + // 启动时的兜底清理还得照旧能清掉上次残留 + cleanLeftoverTempFiles() + for _, p := range inUse { + if _, err := os.Stat(p); err == nil { + t.Errorf("启动时的清理没清掉残留:%s", filepath.Base(p)) + } + } +} diff --git a/danmuburn.go b/danmuburn.go index 461c304..2ee509d 100644 --- a/danmuburn.go +++ b/danmuburn.go @@ -208,6 +208,12 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error { lPrintf("弹幕字号按设置使用 %d(弹幕文件里是 %d)", config.DanmuFontSize, meta.FontSize) meta.FontSize = config.DanmuFontSize } + // 弹幕文件的坐标系和视频对不上时,统一到视频坐标系(见 unifyDanmuSpace) + if burnMode { + if vw, vh := probeVideoSize(ffmpegFile, videoFile); vw > 0 && vh > 0 { + unifyDanmuSpace(&meta, vw, vh, config.DanmuFontSize) + } + } // 弹道按最终字号统一重排,避免还原弹幕或改字号后同弹道追尾 relayoutLanes(records, meta) @@ -329,6 +335,57 @@ func earliestLane(lanes []laneState) int { return best } +// 把渲染坐标系统一到视频分辨率。 +// 弹幕文件里的 PlayResX/PlayResY 是录制当时的画面尺寸,老文件常和现在的视频对不上 +// (例如 960x540 的弹幕文件配 720x1280 的竖屏视频)。libass 会按「视频高 / PlayResY」 +// 缩放文字和它的坐标,而头像叠加用的是视频像素坐标,两者不在同一个坐标系里 —— +// 字号越大越明显:头像显得小,而且每往下一行错得越多(看起来和文字分开了)。 +// 这里把 PlayRes 改成视频宽高:跟随文件时字号按高度比换算(保持占画面的比例), +// 用户显式填了字号就直接用(他看的是成片上的像素)。返回是否做过调整。 +func unifyDanmuSpace(meta *danmuMeta, vw, vh, fontSizeOverride 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 + } + meta.PlayResX, meta.PlayResY = vw, vh + lPrintf("弹幕文件的坐标系是 %dx%d(字号 %d),视频是 %dx%d,改按视频坐标系渲染,字号 %d", + oldX, oldY, oldFs, vw, vh, meta.FontSize) + return true +} + +// 用 ffmpeg 探测视频分辨率,探测不到返回 0, 0 +func probeVideoSize(ffmpegFile, path string) (int, int) { + cmd := exec.Command(ffmpegFile, "-hide_banner", "-i", path) + var buf bytes.Buffer + cmd.Stderr = &buf + // 没指定输出文件时 ffmpeg 会返回非 0,但流信息已经打印出来了 + _ = cmd.Run() + + for _, line := range strings.Split(buf.String(), "\n") { + // 只看视频流,跳过封面图(mjpeg 之类的 attached pic)那几行 + if !strings.Contains(line, "Video:") || strings.Contains(line, "attached pic") { + continue + } + if m := videoSizeRe.FindStringSubmatch(line); len(m) == 3 { + w, _ := strconv.Atoi(m[1]) + h, _ := strconv.Atoi(m[2]) + return w, h + } + } + return 0, 0 +} + +// 逗号后紧跟的「数字x数字」就是分辨率,前面的编码器名里也有 0x… 这种十六进制,靠逗号区分 +var videoSizeRe = regexp.MustCompile(`, (\d{2,5})x(\d{2,5})[, \[]`) + // 用 ffmpeg 探测媒体文件的时长(秒),探测不到返回 0 func probeDuration(ffmpegFile, path string) float64 { cmd := exec.Command(ffmpegFile, "-hide_banner", "-i", path) diff --git a/layout_test.go b/layout_test.go index 91faddc..02939ca 100644 --- a/layout_test.go +++ b/layout_test.go @@ -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) diff --git a/main.go b/main.go index 1fc6de0..ba99a58 100644 --- a/main.go +++ b/main.go @@ -106,6 +106,8 @@ func initialize() { loadConfig() checkConfig() + // 上次在合并途中被关掉的话,录播目录和成片目录里会留下临时文件,启动时清一次 + cleanLeftoverTempFiles() ensureAuthToken() buildStreamers()