用户手动合并一场竖屏录播(视频 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 / 分辨率解析的用例。
211 lines
6.3 KiB
Go
211 lines
6.3 KiB
Go
// 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("本程序结束运行")
|
||
}
|