Files
acfunlive-remake/main.go
T
tamakyi e8b455dfe8 合并时把弹幕坐标系统一到视频分辨率;修「改设置会删掉正在合并的临时文件」
用户手动合并一场竖屏录播(视频 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 / 分辨率解析的用例。
2026-09-25 19:03:21 +08:00

211 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// AcFun 直播弹幕录制工具
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/orzogc/acfundanmu"
)
// 命令行参数处理
func argsHandle() {
const usageStr = "本程序用于录制 AcFun 主播的直播弹幕,在 " + configFile + " 的 uids 里填入要录制弹幕的主播 uid 列表\n" +
"指定 -danmuburn 时进入弹幕合成模式:读取录制弹幕时生成的 jsonl 文件,合成带头像的弹幕层\n" +
"弹幕文件也可以直接给 .ass(原版录制的就是这种),会先还原成 jsonl 再合成"
isHelp := flag.Bool("h", false, "输出本帮助信息")
configDir = flag.String("config", "", "设置文件所在文件夹,默认是本程序所在文件夹")
recordDir = flag.String("record", "", "弹幕文件保存的文件夹,默认是本程序所在文件夹")
danmuburnFile := flag.String("danmuburn", "", "要合成的弹幕文件(.jsonl 或 .ass)路径,指定后进行弹幕合成然后退出,不启动录制")
burnVideo := flag.String("video", "", "录播视频路径,配合 -danmuburn 把弹幕压进视频;不指定则输出透明头像层 mov")
burnOut := flag.String("out", "", "合成输出文件路径,默认与弹幕文件同目录")
burnOffset := flag.Float64("offset", 0, "录播视频相对弹幕录制开始的时间偏移,单位秒,配合 -danmuburn -video 使用")
burnFFmpeg := flag.String("ffmpeg", "", "ffmpeg 可执行文件路径;不指定时读设置文件里的 ffmpegPath,仍旧找不到再按常见位置查找")
burnBitrate = *flag.Int("vbitrate", 0, "合成视频的目标码率(Kbps),0 为默认质量,配合 -danmuburn 使用")
flag.Parse()
if *isHelp {
fmt.Println(usageStr)
flag.PrintDefaults()
os.Exit(0)
}
if flag.NArg() != 0 {
fmt.Println(usageStr)
flag.PrintDefaults()
os.Exit(1)
}
if *danmuburnFile != "" {
loadConfigForDanmuburn()
// 命令行显式给的优先于设置文件里的
if *burnFFmpeg != "" {
config.FFmpegPath = *burnFFmpeg
}
runDanmuburn(*danmuburnFile, *burnVideo, *burnOut, *burnOffset)
os.Exit(0)
}
initialize()
}
// 程序初始化
func initialize() {
exePath, err := os.Executable()
checkErr(err)
exeDir = filepath.Dir(exePath)
if *configDir == "" {
*configDir = exeDir
} else {
info, err := os.Stat(*configDir)
checkErr(err)
if !info.IsDir() {
lPrintErrf("指定的设置文件夹 %s 并不是真正的文件夹", *configDir)
os.Exit(1)
}
}
if *recordDir == "" {
*recordDir = exeDir
} else {
info, err := os.Stat(*recordDir)
checkErr(err)
if !info.IsDir() {
lPrintErrf("指定的弹幕保存文件夹 %s 并不是真正的文件夹", *recordDir)
os.Exit(1)
}
}
configFileLocation = filepath.Join(*configDir, configFile)
if !isConfigFileExist(configFile) {
data, err := json.MarshalIndent(config, "", " ")
checkErr(err)
err = os.WriteFile(configFileLocation, data, 0644)
checkErr(err)
lPrintln("创建设置文件" + configFile + ",请在 uids 里填入要录制弹幕的主播 uid 列表")
}
sInfoMap.info = make(map[int]*streamerInfo)
lInfoMap.info = make(map[string]liveInfo)
streamers.crt = make(map[int]streamer)
streamers.old = make(map[int]streamer)
deviceID, err = acfundanmu.GetDeviceID()
checkErr(err)
if ok := fetchAllRooms(); !ok {
os.Exit(1)
}
liveRooms.rooms = liveRooms.newRooms
loadConfig()
checkConfig()
// 上次在合并途中被关掉的话,录播目录和成片目录里会留下临时文件,启动时清一次
cleanLeftoverTempFiles()
ensureAuthToken()
buildStreamers()
for uid, s := range streamers.crt {
streamers.old[uid] = s
}
}
func main() {
argsHandle()
if len(streamers.crt) == 0 {
lPrintWarn("请在" + configFile + "的 uids 里填入要录制弹幕的主播 uid 列表")
} else {
lPrintln("本程序开始监听主播的直播状态,自动录制直播弹幕")
}
if getFFmpeg() == "" {
lPrintWarn("没有找到 ffmpeg,无法录制直播视频,弹幕合并也无法进行;请安装 ffmpeg 并加入 PATH," +
"或把 ffmpeg 放在本程序所在文件夹,或在 web UI 设置区填写「ffmpeg 路径」")
}
lPrintf("录播文件格式:%s(可在 web UI 或 %s 的 recordFormat 里改成 flv / ts / mkv)",
normalizeRecordFormat(config.RecordFormat), configFile)
mainCh = make(chan controlMsg, 20)
for _, s := range streamers.crt {
st := s
safeGo(st.longID()+"的监听循环", func() { st.cycle("") })
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
mainCtx = ctx
safeGo("监控设置文件", func() { cycleConfig(ctx) })
safeGo("获取直播间数据", func() { cycleFetch(ctx) })
safeGo("清理无用的直播信息", func() { cycleDelKey(ctx) })
if config.WebPort > 0 {
safeGo("web UI 服务器", func() { startWebUI(ctx) })
}
lPrintln("现在可以输入命令控制本程序,输入 help 查看全部命令的解释")
safeGo("命令行输入", handleInput)
// 收到 Ctrl+C(或 Linux 下的终止信号)时走和 quit 命令一样的退出流程,
// 否则进程会直接消失,ffmpeg 变成孤儿进程继续录制
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
safeGo("退出信号监听", func() {
sig := <-sigCh
lPrintln("收到 " + sig.String() + " 信号,正在退出(再按一次可强制退出)")
select {
case mainCh <- controlMsg{c: quit}:
default:
return
}
<-sigCh
lPrintln("再次收到退出信号,强制退出")
os.Exit(1)
})
Outer:
for msg := range mainCh {
switch msg.c {
case startCycle:
m := msg
safeGo(m.s.longID()+"的监听循环", func() { m.s.cycle(m.liveID) })
case quit:
// 结束所有 mainCtx 的子 ctx
cancel()
// 结束 cycle()
lPrintln("正在退出各主播的循环")
sInfoMap.Lock()
for _, info := range sInfoMap.info {
// 退出各主播的循环
if info.ch != nil {
info.ch <- msg
}
}
sInfoMap.Unlock()
lInfoMap.RLock()
for _, info := range lInfoMap.info {
// 结束录制弹幕
if info.isDanmu {
info.danmuCancel()
}
}
lInfoMap.RUnlock()
// 等待 20 秒,等待弹幕文件写入完成
time.Sleep(20 * time.Second)
break Outer
default:
lPrintErrf("未知 controlMsg:%+v", msg)
}
}
lPrintln("本程序结束运行")
}