Files
acfunlive-remake/main.go
T
tamakyi a891094f29 合并默认改用 HEVC、码率按「源片 + 500」自动算;手动合并自动匹配同名弹幕
两件事:

1) 合成码率与编码器
- `mergeBitrate` 填 0(默认)时不再是 ICQ 质量模式,改成「源片视频码率 + 500 Kbps」:
  烧进去的文字和头像都是高频细节,和源片同码率会糊,所以留一点余量;探测不到源片
  码率时才退回质量模式(QSV 用 ICQ 22、软编用 CRF 18),下限 800 Kbps。
- 编码器默认换成硬件 HEVC:候选顺序 hevc_qsv → h264_qsv → libx264,QSV 可用性按
  编码器分别探测(原来只探 h264_qsv)。HEVC 在 mp4 里打 `hvc1` tag,Apple 系
  (QuickTime / Safari / iPhone)才认 —— ffmpeg 默认写的是 hev1。
- 日志现在会打印编码器优先顺序和实际用的码率。

  实测(90 秒真实素材,源片 3217 kbps,密集弹幕):日志「按源片 3217 kbps + 弹幕
  余量 500 kbps 取 3717 kbps」,成片 codec=hevc、tag=hvc1、实际 3.71 Mbps。
  之前同样是这份素材:h264 + ICQ22 会飙到 7386 kbps(79 MB),改成自动码率后
  43.7 MB,体积小了 45% 画质还更稳。

2) 手动合并自动匹配弹幕
- 新增 `GET /api/merge/danmu?video=…`:按视频名找同目录下的同名弹幕,依次试
  `.jsonl`、`.ass`、`.nick.ass`;同目录没有就拿文件名去录播目录再找一次。
- 页面在「手填/粘贴视频后失焦」「多选弹窗选完」「从文件列表多选后」「点预览或
  开始合并时弹幕框还空着」这几种情况下都会自动匹配并提示;弹幕框里已经有内容时
  绝不动它,找不到也保持为空让用户自己选。
- 填回框里的写法与浏览弹窗一致:录播/成片目录下的只写文件名,别处的写绝对路径。

测试:新增 encode_test.go(编码器顺序、参数与 hvc1、自动码率、ffmpeg 输出解析)
和 TestFindDanmuForVideo(7 个子用例:优先级、录播目录回退、绝对路径、找不到返回空);
仓库外的 verify_danmu_match.py 4 组断言全过,verify_pick.py / verify_settings.py 重跑
无回归;go test ./...、gofmt、go vet 干净。
2026-09-25 19:23:44 +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 为按「源片码率 + 500」自动计算,配合 -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("本程序结束运行")
}