Files
acfunlive-remake/utils.go
T
tamakyi 3e16e78003 .ass 时间基准和视频对不上时按第一条弹幕自动对齐,并去掉重复的还原日志
1. 整场弹幕都被判「超出视频时长」,合并直接取消

   上游原版录出来的 .ass 时间不是相对录制起点,而是相对弹幕客户端连上房间的时刻。
   程序先开着、直播中途才开始录的场次,这份 .ass 的零点会比视频起点早一大截
   (用户实测那份早了 7849.92 秒:955 条弹幕、跨度 5436.7 秒,视频只有 5456.9 秒)。
   一律按「视频时间 = 弹幕时间 + 偏移」算的话,这 955 条全都落在 5456.9 秒之外,
   于是「有 955 条弹幕超出了录播视频的时长,已跳过」→「弹幕文件里没有弹幕,取消手动合并」,
   一分半的录播一条弹幕都合不出来。

   这类偏移是能救的:跨度(末 - 首)5436.7 秒装得进 5456.9 秒,整体前移 7849.92 秒之后
   覆盖 0 ~ 5436.7 秒、尾部正好空 20.2 秒 —— 和正常录播的尾部空隙(实测 6~20 秒)吻合,
   是时间基准偏移,不是文件配错。

   新增 fitDanmuToVideo,在探到视频时长之后、裁剪之前介入,触发条件收得很紧:
   只有「按现偏移一条弹幕都用不上」**且**「弹幕跨度 < 视频时长」才整体平移,把第一条
   对齐到视频起点,日志写明平移量、对齐后的覆盖范围和尾部空隙,并提示整体偏早或偏晚
   可以在「偏移」里微调。其余情况完全保持原行为:只是一部分超出 → 照旧只丢尾部;
   跨度本身就装不下 → 一条不留。这样不可能影响任何现在能正常合并的场次。

2. 「已从 xxx.ass 还原出 N 条弹幕」会打印两遍

   页面的「检查」和真正合并会各解析一遍 .ass,而这条日志写在被复用的 parseAssFile 里,
   于是同一句话出现两次,第二遍后面还跟着「已把 xxx.ass 还原成 xxx.jsonl」,看不出谁是谁。
   把这两条日志从 parseAssFile 里摘出去(它改成返回坏行数 bad,由调用方决定要不要告警),
   交给真正落盘那次的调用方 prepareDanmuSource(path, true) 打印,现在还会写明写成了
   哪个文件;坏行警告统一走新增的 logAssBadLines,不再重复。

   顺手给「最后一条弹幕超出视频总长」那条警告补了指引,说明合成时会自动对齐。

3. 测试里后台任务漏到用例结束之后

   safeGo 原来直接把 goroutine 扔出去,用例结束它还在读全局配置,下一个用例开头一改配置,
   -race 就判成数据竞争(而且报在后面的用例上,很难看出真凶)。把「怎么起后台任务」抽成
   var runBackground,生产行为不变,测试里换成可等待的版本,清理时等它收工。

   注意:这个竞争单独 -run 一个用例复现不出来(-race -count=3 也过),必须整包跑
   go test -race . 才会在后续用例上报 race detected。

验证

- gofmt 无输出 / go vet 通过 / go test(182 个,18.5s)全过 / go test -race 整包跑 20 轮
  (13 轮顺序 + 6 轮 -shuffle=on)全过 / staticcheck 无输出 / GOOS=linux go build 通过。
  另:有一轮 -race 曾报过 FAIL、随后 20 轮都没能复现,怀疑是 TestDownloadAvatarCapsResponseBody
  自带的抖动(它的断言依赖「服务端写不完 10MB」,受内核缓冲大小影响),与本批改动无关,
  该文件本批也没动过;待单独确认。
- 反向验证从 32 项扩到 37 项全过(新增:自动对齐 / 只丢尾部不平移 / 跨度装不进不强平移 /
  还原日志只打一次 / 后台任务可等待);脚本新增单用例附加参数 "args" 与不带 -run 的整包用例。
- 用用户那份真实素材(955 条,7849.92 ~ 13286.59 秒,视频 5456.883 秒)端到端跑通:
  不填偏移 + 5460 秒视频 → 日志「弹幕时间基准和视频对不上:955 条弹幕全部落在视频时长
  (5460.0 秒)之外,最早那条在 7849.9 秒;已按「第一条弹幕对齐视频起点」整体前移
  7849.9 秒(偏移 0.0 → -7849.9)」「对齐后弹幕覆盖 0.0 ~ 5436.7 秒,视频总长 5460.0 秒
  (尾部空 23.3 秒)」,分 5 块渲染,exit=0,成片 5462.31 秒 / 23.3 MB。
  再按 jsonl + 偏移反推「哪些时刻该有弹幕」,随机取 40 个应有时刻 + 20 个任意弹幕窗口都
  不覆盖的空屏时刻,抽帧数白像素(白字黑底):60/60 全对,跨块边界(1116/2435/3393/4408)
  与最尾部 t=5435.7 都对得上。
- 60 秒小片新旧对照(真实 9 条弹幕、原始时间戳 7849.92 ~ 7908.7):旧版「有 9 条弹幕超出了
  录播视频的时长(60.1 秒),已跳过」+「jsonl 里没有弹幕」、无成片;新版自动前移 7849.9 秒、
  9 条全画上、成片 34 MB。抽帧看轨迹仍是匀速 10 秒穿屏(danmuShowSeconds),
  头像跟着文字同步走,没有回归。
2026-09-26 17:51:43 +08:00

390 lines
12 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.
// 部分数据和函数定义
package main
import (
"context"
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/orzogc/acfundanmu"
)
type control int
// 控制信息
const (
startCycle control = iota
stopCycle
quit
)
// 主播的管道信息
type controlMsg struct {
s streamer
c control
liveID string
}
// 主播信息
type streamerInfo struct {
ch chan controlMsg // 控制信息
modify bool
}
// 一段录播视频:直播流意外中断后会重新开一段
type recordSegment struct {
file string // 视频文件路径
startNs int64 // 这一段开始录制的时刻,纳秒 Unix
}
// 直播信息
type liveInfo struct {
LiveID string // 直播 ID
uid int // 主播的 uid
isDanmu bool // 是否正在录制直播弹幕
danmuCancel context.CancelFunc // 用来停止录制弹幕
assFile string // 弹幕文件路径
cfg acfundanmu.SubConfig
isRecording bool // 是否正在录制直播视频
recordCancel context.CancelFunc // 用来停止录制视频(中断当前分段)
recordStop context.CancelFunc // 用来结束整个录制循环,避免停止后又被当成意外中断而重启
ffmpegStdin io.WriteCloser // ffmpeg 的 stdin
recordFile string // 视频文件路径
firstRecordFile string // 第一个分段的视频文件路径(与弹幕文件同名配对,用于自动合并)
recordStartNs int64 // 第一个分段开始录制的时刻,纳秒 Unix(用于计算弹幕与录播的时间偏移)
recordSegments []recordSegment // 本场录播的所有分段(中断重连会产生新的分段),合并时全部参与
danmuDone bool // 弹幕录制是否已经结束
recordDone bool // 视频录制是否已经结束
danmuStarted bool // 本场是否启动过弹幕录制
recordStarted bool // 本场是否启动过视频录制
danmuStopped bool // 弹幕是否被用户手动停止(手动停止不触发自动合并)
recordStopped bool // 视频是否被用户手动停止
mergeStarted bool // 自动合并是否已经启动
// 弹幕文件在写盘时出过错(磁盘满、网络盘断开等)。文件是不完整的,
// 拿它合并会得到一个缺弹幕的成片,所以本场跳过自动合并
danmuWriteFailed bool
doneAt int64 // 最近一次录制结束的时刻,纳秒 Unix(用于延迟清理这条记录)
resumeUntil int64 // 断流后等待直播恢复的截止时刻,纳秒 Unix;0 表示没在等待
}
// streamerInfo 的 map
var sInfoMap struct {
sync.Mutex
info map[int]*streamerInfo
}
// liveInfo 的 map
var lInfoMap struct {
sync.RWMutex
info map[string]liveInfo
}
// 设备 ID
var deviceID string
var (
exeDir string // 运行程序所在文件夹
mainCh chan controlMsg // main() 的管道
mainCtx context.Context // main() 的 ctx
configDir *string // 设置文件所在文件夹
recordDir *string // 弹幕文件保存的文件夹
logger = log.New(os.Stdout, "", log.LstdFlags) // 可以同步输出的 logger
itoa = strconv.Itoa // 将 int 转换为字符串
)
// 检查错误
func checkErr(err error) {
if err != nil {
panic(err)
}
}
// 后台任务真正怎么起。单独抽成一个变量是为了让测试能换成「可等待」的版本:
// fire-and-forget 的 goroutine 漏到测试结束之后,会在下一个测试改全局配置时读它们,
// -race 会判成数据竞争
var runBackground = func(f func()) { go f() }
// 后台任务的统一入口:兜底 recover,避免单个任务的 panic 终止整个进程
// name 用于日志里指出是哪个任务出错,f 里可以继续做自己的 recover
func safeGo(name string, f func()) {
runBackground(func() {
defer func() {
if err := recover(); err != nil {
lPrintErrf("%s 发生错误并已恢复,其它录制任务不受影响:%v", name, err)
}
}()
f()
})
}
// 不需要重试的错误:再试一遍也只是重复同样的失败(比如头像本身就超出体积/尺寸上限),
// 硬重试只会白白把几 MB 的响应重新下一遍
type noRetryError struct{ err error }
func (e noRetryError) Error() string { return e.err.Error() }
func (e noRetryError) Unwrap() error { return e.err }
// 把错误标记成「重试也没用」
func noRetry(err error) error {
if err == nil {
return nil
}
return noRetryError{err}
}
// 尝试运行,三次出错后结束运行
func runThrice(f func() error) error {
var err error
for retry := 0; retry < 3; retry++ {
if err = f(); err != nil {
var nr noRetryError
if errors.As(err, &nr) {
return err
}
log.Printf("%v", err)
} else {
return nil
}
time.Sleep(2 * time.Second)
}
return fmt.Errorf("运行三次都出现错误:%v", err)
}
// 获取时间
func getTime() string {
t := time.Now()
return fmt.Sprintf("%d-%02d-%02d %02d-%02d-%02d", t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second())
}
// 打印带时间戳的 log 信息
// lPrintln 定义在 webui.go 里,同时输出到控制台和 web UI 的日志
// 获取时间,按照 log 的时间格式
func getLogTime() string {
t := time.Now()
return fmt.Sprintf("%d/%02d/%02d %02d:%02d:%02d", t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second())
}
// 打印警告信息
func lPrintWarn(msg ...any) {
w := []any{"[WARN]"}
msg = append(w, msg...)
lPrintln(msg...)
}
// 打印错误信息
func lPrintErr(msg ...any) {
e := []any{"[ERROR]"}
msg = append(e, msg...)
lPrintln(msg...)
}
// 打印带时间戳的 log 信息(格式化字符串)
func lPrintf(format string, a ...any) {
lPrintln(fmt.Sprintf(format, a...))
}
// 打印警告信息(格式化字符串)
func lPrintWarnf(format string, a ...any) {
lPrintWarn(fmt.Sprintf(format, a...))
}
// 打印错误信息(格式化字符串)
func lPrintErrf(format string, a ...any) {
lPrintErr(fmt.Sprintf(format, a...))
}
// 返回 ID(UID)形式的字符串
func (s *streamer) longID() string {
return fmt.Sprintf("%s(%d)", s.Name, s.UID)
}
// 日志里用的主播名:只使用内存里已经有的数据,取不到就不发网络请求。
// getName 缓存未命中时会重试 3 次 HTTP(最长 30 多秒),放在日志里会把界面卡住。
func nameForLog(uid int) string {
if s, ok := getStreamer(uid); ok && s.Name != "" {
return s.Name
}
liveRooms.RLock()
defer liveRooms.RUnlock()
if room, ok := liveRooms.rooms[uid]; ok {
return room.name
}
return ""
}
// 返回 ID(UID)形式的字符串,用于日志,取不到名字时只打 uid
func longID(uid int) string {
name := nameForLog(uid)
if name == "" {
return itoa(uid)
}
return fmt.Sprintf("%s(%d)", name, uid)
}
// 根据 uid 获取 liveInfo
func getLiveInfoByUID(uid int) (infoList []liveInfo, ok bool) {
lInfoMap.RLock()
defer lInfoMap.RUnlock()
for _, info := range lInfoMap.info {
if info.uid == uid {
infoList = append(infoList, info)
ok = true
}
}
return infoList, ok
}
// 根据 liveID 获取 liveInfo
func getLiveInfo(liveID string) (liveInfo, bool) {
lInfoMap.RLock()
defer lInfoMap.RUnlock()
if info, ok := lInfoMap.info[liveID]; ok {
return info, true
}
return liveInfo{}, false
}
// 原地更新 liveInfo 的部分字段,不覆盖其他字段(弹幕和视频录制共用同一个 liveInfo)
func updateLiveInfo(liveID string, f func(*liveInfo)) {
lInfoMap.Lock()
defer lInfoMap.Unlock()
info, ok := lInfoMap.info[liveID]
if !ok {
info = liveInfo{LiveID: liveID}
}
f(&info)
lInfoMap.info[liveID] = info
}
// 根据 liveID 查询是否正在录制直播弹幕
func isDanmu(liveID string) bool {
if info, ok := getLiveInfo(liveID); ok {
return info.isDanmu
}
return false
}
// 跨盘搬运成片时的临时后缀:拷一半失败也能一眼看出不是成品
const partSuffix = ".acfunlive.part"
// 合成时的临时成片放在弹幕文件所在目录(本地),
// 完成后再落到成片目录 —— 成片目录可能是网络盘或外接盘,直接往里写、
// 写到一半失败会在那里留下一个看起来正常的半成品。文件名沿用临时前缀,
// 所以不会出现在文件管理的列表里,启动时也会清理残留。
func tempMergeOutput(danmuFile, outBase string) string {
return filepath.Join(filepath.Dir(danmuFile), chunkTempPrefix+"merging_"+outBase)
}
// 把合成好的本地临时文件落到成片位置:同盘直接改名(瞬时),跨盘退回拷贝
func placeOutput(tmp, out string) error {
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
return fmt.Errorf("创建成片文件夹失败:%v", err)
}
if err := os.Rename(tmp, out); err == nil {
return nil
}
// 跨盘时 os.Rename 会失败(Windows 上尤其明确),退回拷贝:
// 先拷成 .part 再改名,中途失败也不会在成片目录留下像成品的文件
part := out + partSuffix
total := fileSizeOrZero(tmp)
if err := copyFileWith(tmp, part, func(done int64) {
if total > 0 {
jobSetPhasePercent(float64(done) / float64(total))
}
}); err != nil {
_ = os.Remove(part)
return fmt.Errorf("拷贝到成片目录 %s 失败:%v", filepath.Dir(out), err)
}
if err := os.Rename(part, out); err != nil {
_ = os.Remove(part)
return fmt.Errorf("成片改名失败:%v", err)
}
_ = os.Remove(tmp)
return nil
}
// 拷贝文件,跨盘搬运成片时用
func copyFile(src, dst string) error {
return copyFileWith(src, dst, nil)
}
// 拷贝文件。onBytes 不为空时按累计已拷贝字节回调,跨盘搬运成片时用来报进度
func copyFileWith(src, dst string, onBytes func(int64)) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
buf := make([]byte, 1<<20)
var done int64
for {
n, rerr := in.Read(buf)
if n > 0 {
if _, werr := out.Write(buf[:n]); werr != nil {
_ = out.Close()
return werr
}
done += int64(n)
if onBytes != nil {
onBytes(done)
}
}
if rerr == io.EOF {
break
}
if rerr != nil {
_ = out.Close()
return rerr
}
}
return out.Close()
}
// 清掉上次运行残留的合成临时文件。正常结束时都会删掉,这里是兜底:
// 程序在合成中途被强杀时,临时文件会留在录播目录或成片目录里。
// 注意强杀不会连带杀掉 ffmpeg 子进程,它可能还在写这个文件(Windows 上就删不掉),
// 所以删不掉时只提示一句,下次启动会再清一次。
func cleanTempFiles(dir string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
name := e.Name()
// 预缩放头像是个目录(chunkAvatarDir),别被整个跳过
if e.IsDir() {
if strings.HasPrefix(name, chunkTempPrefix) {
path := filepath.Join(dir, name)
if err := os.RemoveAll(path); err == nil {
lPrintln("清理上次留下的临时文件夹:" + name)
} else {
lPrintWarnf("临时文件夹 %s 现在删不掉,下次启动会自动清理:%v", path, err)
}
}
continue
}
if !strings.HasPrefix(name, chunkTempPrefix) && !strings.HasSuffix(name, partSuffix) {
continue
}
path := filepath.Join(dir, name)
if err := os.Remove(path); err == nil {
lPrintln("清理上次留下的临时文件:" + name)
} else {
lPrintWarnf("临时文件 %s 现在删不掉(多半是上次强杀程序后残留的 ffmpeg 还在写),下次启动会自动清理:%v", path, err)
}
}
}