// 弹幕录制相关 package main import ( "context" "encoding/json" "fmt" "os" "path/filepath" "strconv" "strings" "time" "unicode/utf8" "github.com/orzogc/acfundanmu" ) // 不同的视频分辨率对应的弹幕字幕设置 var subConfigs = map[int]acfundanmu.SubConfig{ 0: {PlayResX: 720, PlayResY: 1280, FontSize: 60}, // 这是手机直播,有一些是 540X960 540: {PlayResX: 960, PlayResY: 540, FontSize: 30}, 720: {PlayResX: 1280, PlayResY: 720, FontSize: 40}, 1080: {PlayResX: 1920, PlayResY: 1080, FontSize: 60}, } // 获取弹幕字幕设置,根据直播源的码率选择 func (s *streamer) getSubConfig() (cfg acfundanmu.SubConfig, e error) { defer func() { if err := recover(); err != nil { e = fmt.Errorf("getSubConfig() error: %v", err) } }() var ac *acfundanmu.AcFunLive var err error err = runThrice(func() error { ac, err = acfundanmu.NewAcFunLive(acfundanmu.SetLiverUID(int64(s.UID))) return err }) checkErr(err) sInfo := ac.GetStreamInfo() // 选择码率最高的直播源 index := len(sInfo.StreamList) - 1 bitrate := sInfo.StreamList[index].Bitrate switch { case bitrate >= 4000: cfg = subConfigs[1080] case len(sInfo.StreamList) >= 2 && bitrate >= 2000: cfg = subConfigs[720] case bitrate == 0: cfg = subConfigs[0] default: cfg = subConfigs[540] } return cfg, nil } // ==================== 弹幕数据双路输出 ==================== // 弹幕持续时间(纳秒),与 acfundanmu 保持一致 const danmuShowDur = int64(10 * time.Second) // 弹道数量上限,与 acfundanmu 的 queueLen 保持一致 const laneNum = 100 // ass 文件的 Script Info,与 acfundanmu 保持一致 const scriptInfoTmpl = `[Script Info] ; LiveID: %s ; StreamName: %s Title: %s ScriptType: v4.00+ Collisions: Normal PlayResX: %d PlayResY: %d ` // ass 文件的 V4+ Styles,与 acfundanmu 保持一致 const stylesTmpl = `[V4+ Styles] Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding Style: Danmu,Microsoft YaHei,%d,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,20,20,2,0 ` // ass 文件的 Events const eventsTmpl = `[Events] Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text ` // 原格式弹幕行,与 acfundanmu 的 dialogue 保持一致 const dialogueTmpl = "Dialogue: 0,%s,%s,Danmu,%s(%d),20,20,2,,{\\move(%d,%d,%d,%d)}%s\n" // 昵称头像版弹幕行,\an7 让锚点固定在文本左上角,前导全角空格是头像占位 const dialogueNickTmpl = "Dialogue: 0,%s,%s,Danmu,%s(%d),20,20,2,,{\\an7\\move(%d,%d,%d,%d)}%s\n" // jsonl 首行的元数据 type danmuMeta struct { PlayResX int `json:"playResX"` PlayResY int `json:"playResY"` FontSize int `json:"fontSize"` StartTime int64 `json:"startTime"` // 录制开始的时间,纳秒 Unix } // jsonl 的弹幕记录 type danmuRecord struct { Time float64 `json:"t"` // 相对录制开始的秒数 UID int64 `json:"uid"` Name string `json:"name"` Avatar string `json:"avatar"` // 头像 URL,弹幕协议自带 Content string `json:"content"` Lane int `json:"lane"` // 在昵称头像版 ASS 里的弹道行 Width int `json:"width"` // 昵称头像版文本的估算像素宽度 } // 弹道的占用状态(纳秒) type laneState struct { appear int64 emerge int64 disappear int64 } // ass 文件里弹幕出现或消失的时间格式,与 acfundanmu 保持一致 type assTime int64 func (t assTime) String() string { if t < 0 { return "0:00:00.00" } tt := time.Unix(0, int64(t)).UTC() return fmt.Sprintf("%d:%02d:%02d.%02d", tt.Hour(), tt.Minute(), tt.Second(), tt.Nanosecond()/1e7) } // 估算文本的渲染宽度,CJK 及全角字符按全宽、其余按半宽计算 func textWidth(text string, fontSize int) int { w := 0 for _, r := range text { if r >= 0x2E80 { w += fontSize } else { w += fontSize / 2 } } return w } // 在弹道数组里寻找空闲弹道,返回弹道行号,没有空闲返回 -1,公式与 acfundanmu 保持一致 func placeLane(last []laneState, dt laneState, leftTime int64) int { for i, t := range last { if dt.appear > t.emerge && leftTime > t.disappear { last[i] = dt return i } } return -1 } // 弹幕数据双路输出:原格式 ASS + 昵称头像版 ASS + jsonl type danmuWriter struct { cfg acfundanmu.SubConfig ass *os.File // 原格式 nick *os.File // 昵称头像版 jsonl *os.File lastO []laneState // 原格式的弹道状态 lastN []laneState // 昵称头像版的弹道状态 } // 返回昵称头像版 ASS 和 jsonl 的文件路径 func nickJsonlFile(assFile string) (string, string) { base := strings.TrimSuffix(assFile, ".ass") return base + ".nick.ass", base + ".jsonl" } // 创建弹幕写入器,覆盖写文件头,重启续录时沿用同一个写入器即可 func newDanmuWriter(liveID, streamName string, cfg acfundanmu.SubConfig, assFile string) *danmuWriter { nickFile, jsonlFile := nickJsonlFile(assFile) w := &danmuWriter{ cfg: cfg, lastO: make([]laneState, laneNum), lastN: make([]laneState, laneNum), } var err error w.ass, err = os.OpenFile(assFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) checkErr(err) w.nick, err = os.OpenFile(nickFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) checkErr(err) w.jsonl, err = os.OpenFile(jsonlFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) checkErr(err) fmt.Fprintf(w.ass, scriptInfoTmpl, liveID, streamName, cfg.Title, cfg.PlayResX, cfg.PlayResY) fmt.Fprintf(w.ass, stylesTmpl, cfg.FontSize) fmt.Fprint(w.ass, eventsTmpl) fmt.Fprintf(w.nick, scriptInfoTmpl, liveID, streamName, cfg.Title+" 昵称头像版", cfg.PlayResX, cfg.PlayResY) fmt.Fprintf(w.nick, stylesTmpl, cfg.FontSize) fmt.Fprint(w.nick, eventsTmpl) meta, err := json.Marshal(danmuMeta{PlayResX: cfg.PlayResX, PlayResY: cfg.PlayResY, FontSize: cfg.FontSize, StartTime: cfg.StartTime}) checkErr(err) w.jsonl.Write(meta) w.jsonl.Write([]byte("\n")) return w } // 写入一条弹幕 func (w *danmuWriter) write(c *acfundanmu.Comment) { s := w.cfg sendTime := c.SendTime * 1e6 rel := sendTime - s.StartTime // 原格式 ASS,碰撞避让和坐标公式与 acfundanmu 完全一致 length := utf8.RuneCountInString(c.Content) * s.FontSize dtO := laneState{ appear: rel, emerge: rel + (int64(length)*danmuShowDur)/int64(s.PlayResX+length), disappear: rel + danmuShowDur, } leftO := rel + (int64(s.PlayResX)*danmuShowDur)/int64(s.PlayResX+length) if i := placeLane(w.lastO, dtO, leftO); i >= 0 { fmt.Fprintf(w.ass, dialogueTmpl, assTime(dtO.appear), assTime(dtO.disappear), strings.ReplaceAll(c.Nickname, ",", " "), c.UserID, s.PlayResX+length/2, s.FontSize*(i+1), -length/2, s.FontSize*(i+1), c.Content) } // 昵称头像版 ASS,\an7 锚点在文本左上角,头像由 ffmpeg 叠在文字左侧,不依赖字体渲染宽度 text := c.Nickname + "(" + strconv.FormatInt(c.UserID, 10) + "):" + c.Content width := textWidth(text, s.FontSize) avW, avGap := avatarSize(s.FontSize), avatarGap(s.FontSize) total := avW + avGap + width // 头像、间隔和文字的总宽度 dtN := laneState{ appear: rel, emerge: rel + (int64(total)*danmuShowDur)/int64(s.PlayResX+total), disappear: rel + danmuShowDur, } leftN := rel + (int64(s.PlayResX)*danmuShowDur)/int64(s.PlayResX+total) if i := placeLane(w.lastN, dtN, leftN); i >= 0 { fmt.Fprintf(w.nick, dialogueNickTmpl, assTime(dtN.appear), assTime(dtN.disappear), strings.ReplaceAll(c.Nickname, ",", " "), c.UserID, s.PlayResX+avW+avGap, s.FontSize*i, -width, s.FontSize*i, text) rec := danmuRecord{ Time: float64(rel) / 1e9, UID: c.UserID, Name: c.Nickname, Avatar: c.Avatar, Content: c.Content, Lane: i, Width: width, } b, err := json.Marshal(rec) checkErr(err) w.jsonl.Write(b) w.jsonl.Write([]byte("\n")) } } // 消费弹幕队列直到结束,返回说明队列已结束(出错或主播下播) func (w *danmuWriter) consume(ctx context.Context, ac *acfundanmu.AcFunLive) { for { select { case <-ctx.Done(): return default: danmu := ac.GetDanmu() if danmu == nil { return } for _, d := range danmu { if c, ok := d.(*acfundanmu.Comment); ok { w.write(c) } } } } } // 关闭所有文件 func (w *danmuWriter) close() { w.ass.Close() w.nick.Close() w.jsonl.Close() } // ==================== 录制流程 ==================== // 录制直播弹幕 func (s streamer) getDanmu(ctx context.Context, info liveInfo) { defer func() { if err := recover(); err != nil { lPrintErr("Recovering from panic in getDanmu(), the error is:", err) lPrintErrf("录制%s的直播弹幕发生错误,如要重启录制,请运行 startdanmu %d", s.longID(), s.UID) } }() lPrintln("开始录制" + s.longID() + "的直播弹幕") lPrintln("本次录制的 ass 文件保存在" + info.assFile) lPrintf("如果想提前结束录制%s的直播弹幕,运行 stopdanmu %d", s.longID(), s.UID) defer s.moveDanmuFiles(info.assFile) ac, err := acfundanmu.NewAcFunLive(acfundanmu.SetLiverUID(int64(s.UID))) checkErr(err) _ = ac.StartDanmu(ctx, false) streamInfo := ac.GetStreamInfo() w := newDanmuWriter(streamInfo.LiveID, streamInfo.StreamName, info.cfg, info.assFile) defer w.close() w.consume(ctx, ac) // 断流后先观察一个窗口:网络波动或主播短暂掉线(20 秒内)恢复时沿用同一个写入器接着写, // 弹幕时间轴和视频分段保持一致,最终和视频一起合成同一个成片 fails := 0 for { markResumeWait(info.LiveID) resumed := s.waitResume(ctx) clearResumeWait(info.LiveID) if !resumed { break } if fails >= maxResumeRetry { lPrintWarnf("重连%s的直播弹幕连续失败 %d 次,结束本场弹幕录制", s.longID(), fails) break } lPrintWarn("因意外结束录制" + s.longID() + "的直播弹幕,尝试重启录制") begin := time.Now() ac, err := acfundanmu.NewAcFunLive(acfundanmu.SetLiverUID(int64(s.UID))) if err != nil { lPrintErrf("重连%s的直播弹幕失败:%v", s.longID(), err) fails++ time.Sleep(time.Second) continue } _ = ac.StartDanmu(ctx, false) w.consume(ctx, ac) // 连上几秒就断开说明这次重连没成功,计数避免一直高频重连 if time.Since(begin) < 5*time.Second { fails++ } else { fails = 0 } time.Sleep(5 * time.Second) } lPrintln(s.longID() + "的直播弹幕录制已经结束") } // 退出直播弹幕录制相关操作 func (s streamer) quitDanmu(liveID string) { finishDanmuPart(liveID) } // 初始化弹幕录制 func (s streamer) initDanmu(ctx context.Context, liveID, filename string) { dctx, dcancel := context.WithCancel(ctx) defer dcancel() if info, ok := getLiveInfo(liveID); ok && info.isDanmu { lPrintWarnf("已经在录制%s的直播弹幕,如要重启录制,请先运行 stopdanmu %d", s.longID(), s.UID) return } // 获取弹幕字幕设置 cfg, err := s.getSubConfig() if err != nil { lPrintErr(err) lPrintErrf("无法获取%s的直播源,退出录制直播弹幕,请确定主播正在直播,如要重启录制直播弹幕,请运行 startdanmu %d", s.longID(), s.UID) return } info := liveInfo{LiveID: liveID, uid: s.UID, cfg: cfg} assFile := transFilename(filename) if assFile == "" { return } info.assFile = assFile + ".ass" info.cfg.Title = filepath.Base(assFile) info.cfg.StartTime = time.Now().UnixNano() info.isDanmu = true info.danmuCancel = dcancel updateLiveInfo(liveID, func(i *liveInfo) { i.uid = info.uid i.isDanmu = true i.danmuStarted = true i.danmuCancel = info.danmuCancel i.assFile = info.assFile i.cfg = info.cfg }) defer s.quitDanmu(info.LiveID) s.getDanmu(dctx, info) } // 临时录制指定主播的直播弹幕 func startDanmu(uid int) bool { s, ok := getStreamer(uid) if !ok { name := getName(uid) if name == "" { lPrintWarnf("不存在 uid 为%d的用户", uid) return false } s = streamer{UID: uid, Name: name} } liveID := getLiveID(uid) if liveID == "" { lPrintErr(s.longID() + "不在直播,取消录制直播弹幕") return false } if isDanmu(liveID) { lPrintWarnf("已经在录制%s的直播弹幕,如要重启录制,请先运行 stopdanmu %d", s.longID(), s.UID) return false } filename := getTime() + " " + s.Name + " " + s.getTitle() // goroutine 是为了快速返回 safeGo(s.longID()+"的弹幕录制", func() { s.initDanmu(mainCtx, liveID, filename) }) return true } // 停止录制指定主播的直播弹幕 func stopDanmu(uid int) bool { infoList, ok := getLiveInfoByUID(uid) if !ok { lPrintWarnf("没有在录制 uid 为%d的主播的直播弹幕", uid) return true } for _, info := range infoList { if info.isDanmu { lPrintf("开始停止录制%s的liveID为%s直播弹幕", longID(uid), info.LiveID) updateLiveInfo(info.LiveID, func(i *liveInfo) { i.danmuStopped = true }) info.danmuCancel() } } return true }