Files
acfunlive-remake/danmuburn.go
T
tamakyi a409c5b4dd 新增「转码」分区与手动合并逐任务配置,修掉 PC 端图表文字被拉扁
一、PC 端图表文字被拉伸(bug)
.spark 折线图把 viewBox 写死成 "0 0 300 48" 又带 preserveAspectRatio="none":
移动端元素宽≈300px 时缩放≈1 看不出问题,PC 上卡片宽约 1030px,横轴被放大 3.4 倍
而纵轴仍是 48px,图里的 <text> 被整体拉扁。现在按元素实际宽度算 viewBox 宽度
(取不到时回落 300),并监听 resize 重画;preserveAspectRatio 保留不影响 1:1 显示。

二、新增「转码」分区(transcode.go,第七个 tab)
原来只有「录播 + 弹幕 → 成片」一条路,已有成片想压小、换 HEVC、降分辨率只能手搓命令行。
现在从文件列表多选加入,每个文件一行独立配置:编码器、码率控制(跟随源片 / 目标码率 /
质量 CRF·ICQ / 源片百分比)、编码速度、输出帧率、分辨率(只列不高于源片的档)、
音频(拷贝 / 128k / 192k / 去掉),并实时预估产物大小与整批合计。
 - POST /api/transcode/probe | estimate | transcode,都在既有的口令鉴权之后
 - 目标码率模式按「码率 × 时长」出准数;质量模式用单调系数表插值并标注是粗估
 - 落盘沿用合并那套:先在源文件所在目录生成,完成后再 placeOutput 到输出目录,
   失败或中断删临时文件,不留半成品
 - 与合并共用同一张进度卡片和任务位(mergeSlot),串行排队,可暂停 / 停止

三、手动合并改成「一次排多个任务」
原来一次只能合一场。现在多选视频后按「文件名里的录制时间 + 主体名」自动归组
(相邻两段间隔 2 小时内算同一场),每组一个任务卡,各自配弹幕、偏移、输出名和
合成参数(码率 / 帧率 / 字号 / 编码器),可拆组、可移除;服务端串行跑,逐个回传结果。
请求体从 {videos,danmu,...} 扩成 {jobs:[{...}]},老的单任务写法继续可用。

四、审阅中一并修掉 / 加固的点
 - 容器总码率含音频,直接当视频码率用会错两处:预估体积时又加一遍音轨(算成双份),
   「跟随源片」时把音轨码率也编进视频里。现在分开记「视频流码率」与「容器总码率」,
   转码侧按「视频流码率 → 容器码率 − 音轨 → 体积 ÷ 时长 − 音轨」三级取值;
   合并的自动码率仍按老口径(容器含音频),单独用 streamOrContainerKbps 锁住
 - 源片本来没有音轨时,预估不再凭空加 128 kbps 音频
 - 目标码率加上界(200 Mbps),单位写错的数在开跑前就被挡住(合并的逐任务码率同理)
 - 探测结果加缓存(ffmpeg + 路径 + 体积 + 修改时间),体积估算跟着输入框重发时不再
   每次都起一个 ffmpeg;文件一变(录制中追加、换了文件)缓存自然失效
 - 只有音轨 / 只有封面图的文件、以及正在写入的文件,排队时就被挡住并说清原因
 - probeMedia / parseMediaInfo 合并了原先重复的两套解析(probeDuration 与
   probeVideoStream 各起一次 ffmpeg 解析同一段输出),burnDanmu 现在一次探测拿全
 - 前端:估算参数非法时把服务端给的原因显示在合计行(原来静默不动,像是没改成功);
   估算请求加序号,慢的旧回包不再覆盖新结果;转码下拉刷新时保留已勾选项;
   跑完的行标「完成 / 失败」并淡化;删掉没有对应元素、从未使用的 CSS 规则

验证
- gofmt / go vet 干净;go build(windows)与 GOOS=linux go build 均通过
- go test ./... 20.9s 全过;go test -race ./... 26.1s 全过(182 → 206 个用例,
  transcode_test.go 新增 20 个)
- 反向验证 C:\OtherFiles\acf-test\reverse_check.py 从 37 项加到 43 项,全过
- 51899 隔离实例 + 真 ffmpeg 冒烟 29 项全过:401 鉴权、probe 的 hasVideo/hasAudio、
  ts 容器(不带每流码率)探测到的视频码率 = 容器 429 − 音轨 127、
  HEVC 按 0.65 折算、参数非法 400 带原因、只有音轨的文件排队即被拒、
  输出目录不存在被拒、两任务队列真转成功并落到指定目录、缩放成片高度确实是 240、
  日志里带「第 n/N 个」和参数摘要
- 页面 jsdom 冒烟 37 项全过、无 JS 报错(分组 / 拆组 / 逐任务参数 / 转码行渲染 /
  跳过无视频轨文件 / 估算失败提示 / 结果徽标)
2026-09-27 10:36:29 +08:00

2063 lines
72 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.
// 弹幕合成相关:读取 jsonl,下载头像并圆形裁切,调用 ffmpeg 合成带头像的弹幕层
package main
import (
"bufio"
"bytes"
"crypto/sha1"
"encoding/json"
"errors"
"fmt"
"image"
"image/color"
"image/draw"
_ "image/gif"
_ "image/jpeg"
"image/png"
"io"
"math"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
xdraw "golang.org/x/image/draw"
_ "golang.org/x/image/webp"
)
// 头像缓存目录名
const avatarCacheDir = "avatar_cache"
// 把这个阶段的任务分几路并发。查链接和下图都要走网络,一个 uid 的链接反查实测约 1 秒,
// 串行做几百人的场次要几分钟;AcFun 的接口和图片 CDN 都吃得住几路并发,先给 6 路。
// 暂停/停止仍然在每个任务开头过闸门,所以并发不影响暂停和停止的响应
const avatarFetchWorkers = 6
// 头像这一步的统计,只用来打一句看得懂的汇总
type avatarStat struct {
done int // 已处理
fetched int // 真向 AcFun 查过链接的
reusedLink int // 链接直接来自上次的记录(没发请求)
imgReused int // 图片用的已有缓存
imgDownloaded int // 图片这次才下下来
failed int // 链接或图片拿不到,这个用户只烧文字
}
// 头像缓存放程序所在目录:换保存目录(甚至换盘)都还能复用,
// 不用为同一批观众重新下一次。旧版本放在弹幕文件同目录,读取时会搬过来。
func avatarCachePath() string {
if exeDir == "" {
return avatarCacheDir
}
return filepath.Join(exeDir, avatarCacheDir)
}
// 命令行 -vbitrate 指定的合成目标码率(Kbps),配合 config.MergeBitrate 使用
var burnBitrate int
// 缓存头像的边长
const avatarCacheSize = 256
// jsonl 里没有弹幕记录
var errNoDanmu = errors.New("jsonl 里没有弹幕")
// 弹幕在屏幕上的停留时长(秒),与 danmu.go 里的 danmuShowDur 一致
const danmuShowSeconds = 10.0
// 每块最多包含的弹幕条数
// 每条弹幕都要生成一段 overlay 并作为 -filter_complex 的单个参数传给 ffmpeg,
// 而 Windows 的命令行总长上限是 32767 字符(Linux 是单个参数 128KB),
// 弹幕一多整条命令就会超限且报错完全看不出原因,所以超过这个数量就分块渲染再拼接。
const maxDanmuPerChunk = 80
// 单块绘制条数的硬上限(跨块续画 + 本块新出现的条数之和)。
//
// 每条弹幕在 -filter_complex 里占一段 overlay(约 105 字符),每个用户还要占一行
// 「-i 头像」(预缩放后是 acfunlive_chunk_av/12.png 这种相对路径,约 30 字符)。
// 最坏情况——每块里每条弹幕都来自不同用户、文字宽度顶满整屏——实测每条约 130 字符,
// 200 条 ≈ 26600 字符,离 Windows 的 32767 和自检阈值 30000 都还有余量
// (command_len_test.go 会真跑一遍这个最坏情况)。原来单块只算「本块新出现」的
// 120 条,现在续画也要算进来,所以这个数就是「一块里能画多少条」的真实预算。
//
// 历史:2026-09-26 线上崩过两次都是这里 —— 先是 200 条时实测 48288 字符(那时每条
// 按 241 字符算,因为头像走的是绝对路径 + 每条一个 scale 行),后来收到 120 条;
// 头像改成「预缩放 + 短相对路径」后才腾出空间放回 200。
const maxDanmuPerChunkHard = 200
// 跨块续画:一条弹幕在屏上停留 danmuShowSeconds 秒,块边界把它切成两半时,
// 后一块必须接着画(见 splitDanmuChunks)。续画会把预算吃掉,实在放不下时宁可
// 挤掉最早的那几条(它们已经快到左边缘了),也要保证每块至少有这么多条新弹幕 ——
// 否则块长会退化成 0,无限细分下去。
const minNewDanmuPerChunk = 20
// 分块渲染产生的临时文件前缀
const chunkTempPrefix = "acfunlive_chunk_"
// 预缩放头像的临时文件夹(放在录播目录下)。名字带 chunkTempPrefix,
// 文件列表和启动清理都会认它;合并结束时整个目录删掉。
const chunkAvatarDir = chunkTempPrefix + "av"
// Windows 的 CreateProcess 限制整个命令行 32767 字符(Linux 是单个参数 128KB)。
// 留一点余量,超了提早报错。这一版 ffmpeg(n9.0.2)**没有** -filter_complex_script,
// 滤镜图只能整段放在命令行里,所以滤镜里的路径长度直接决定单块能画多少条弹幕
const maxCommandLineLen = 30000
// 估算交给 exec 的命令行总长:每个参数按「一个空格 + 内容」算
func commandLength(exe string, args []string) int {
n := len(exe)
for _, a := range args {
n += len(a) + 1
}
return n
}
// 预估成片体积时给音频留的码率(Kbps):分块时音频固定重编码成 192k,
// 单块时是直接拷流,这里统一按 192 估,反正渲染过 5% 后会被实测外推值顶掉
const estimateAudioKbps = 192
// 一个待渲染的弹幕块
type danmuChunk struct {
start float64 // 在视频时间轴上的起点(秒)
end float64 // 终点(秒),math.Inf(1) 表示一直到视频结尾
carried int // records 里前多少条是上一块尾还在屏上的续画
records []danmuRecord // 这一块要画的弹幕:续画 + 本块新出现的
}
// 渲染分块时共用的参数
type burnContext struct {
ffmpegFile string
videoFile string // 为空表示输出透明头像层
outFile string
offset float64
meta danmuMeta
avatars map[int64]string // uid → 预缩放头像的相对路径(见 prepareAvatarInputs)
bitrate int
dir string
encoder string // 已经验证可用的编码器,分块时避免每块都重新回退
outFps int // 加到滤镜里的输出帧率,0 = 跟随源片、不加 fps 滤镜
layerFps int // 透明头像层模式的帧率(那种模式没有源片可跟)
}
// 透明头像层(无源片模式)默认的帧率
const defaultLayerFps = 30
// 一次合成的参数。手动合并里每个任务可以自带一套,不带的项回落到全局设置。
//
// 码率、帧率、字号都用指针:0 本身是有明确含义的(自动码率 / 跟随源片 / 自动字号),
// 用普通 int 分不清「没填」和「填了 0」,手动合并就会把用户在全局设置里填的值顶掉
type mergeOptions struct {
Bitrate *int `json:"bitrate,omitempty"` // 合成码率(Kbps),0 = 按「源片码率 + 弹幕余量」自动
Fps *int `json:"fps,omitempty"` // 输出帧率,0 = 跟随源片
FontSize *int `json:"fontSize,omitempty"` // 弹幕字号(像素),0 = 按画面分辨率自动
Encoder string `json:"encoder,omitempty"` // 指定 ffmpeg 编码器,空或 auto = 自动
}
// 取本次合成用的码率 / 帧率 / 字号,没指定就沿用全局设置
func (o mergeOptions) bitrate() int {
if o.Bitrate != nil {
return *o.Bitrate
}
return config.MergeBitrate
}
func (o mergeOptions) fps() int {
if o.Fps != nil {
return *o.Fps
}
return config.MergeFps
}
func (o mergeOptions) fontSize() int {
if o.FontSize != nil {
return *o.FontSize
}
return config.DanmuFontSize
}
// 指定的编码器(空表示让 ffmpeg 那边按可用性自动挑)
func (o mergeOptions) encoder() string {
if o.Encoder == "" || o.Encoder == "auto" {
return ""
}
return o.Encoder
}
// 检查一次合成的参数,返回给用户的错误说明(空字符串表示通过)
func (o mergeOptions) validate(ffmpegFile string) string {
if o.Bitrate != nil && (*o.Bitrate < 0 || *o.Bitrate > maxBitrateKbps) {
return fmt.Sprintf("合成码率要在 0(自动)到 %d(Kbps)之间", maxBitrateKbps)
}
if o.Fps != nil && (*o.Fps < 0 || *o.Fps > maxMergeFps) {
return fmt.Sprintf("输出帧率只能在 0(跟随源片)到 %d 之间", maxMergeFps)
}
if o.FontSize != nil && *o.FontSize < 0 {
return "弹幕字号不能小于 0(0 表示按画面分辨率自动)"
}
if enc := o.encoder(); enc != "" {
for _, n := range availableEncoders(ffmpegFile) {
if n == enc {
return ""
}
}
return fmt.Sprintf("本机没有可用的编码器 %s,可用的是:%s",
enc, strings.Join(availableEncoders(ffmpegFile), "、"))
}
return ""
}
// 设置里的输出帧率 vs 源片帧率:只在目标**更高**时才加 fps 滤镜 —— 降帧只会更卡。
// 源片帧率探测不到(0)时也不动,免得把高帧率源压到某个值
func outputFps(target int, source float64) int {
if target <= 0 || source <= 0 {
return 0
}
if float64(target) <= source+0.01 {
return 0
}
return target
}
// 透明头像层模式没有源片可跟:设置里填了就按填的,否则固定 30(与老版本一致)
func layerFps(target int) int {
if target > 0 {
return target
}
return defaultLayerFps
}
// 透明头像层那一侧实际用的帧率。手工构造的 burnContext 可能没填这个字段,
// 直接用会让 ffmpeg 拿 r=0 去初始化 color 源,报 EINVAL 整块失败
func (c *burnContext) layerRate() int {
if c.layerFps > 0 {
return c.layerFps
}
return defaultLayerFps
}
// 头像直径,与文字的实际墨迹高度一致(字号是 em 框,墨迹约为 0.8 倍)
// 头像直径:跟随弹幕字号。字号特别小时(有人把 danmuFontSize 填成 1、2),
// 算出来是 0,而 scale=0:0 会让 ffmpeg 直接以 EINVAL 退出、整块渲染失败
func avatarSize(fontSize int) int {
n := fontSize * 4 / 5
if n < 1 {
n = 1
}
return n
}
// 头像和弹幕文字之间的间隔
func avatarGap(fontSize int) int {
return fontSize / 6
}
// 弹幕在绝对时间 t 时,文字左边缘的 x 坐标(ASS 坐标,像素)。
//
// 弹幕从画面右外侧匀速滚到左外侧,全程 (画面宽 + 自身总宽) 像素、耗时 danmuShowSeconds,
// 所以位置只由 (t - t0) 决定。文字层(libass 的 \move)和头像层(overlay 的 x 表达式)
// **必须用同一个公式**:分块渲染会把弹幕的显示区间截到块边界上,两边一旦各算各的,
// 被截断的弹幕就会出现「文字越走越快、头像还按 10 秒慢慢挪」——
// 线上看到的就是「头像中途偏离弹幕轨迹」,实测 66.4% 的弹幕都被截断过。
func danmuTextX(playResX, avPad, total int, t, t0 float64) float64 {
return float64(playResX+avPad) - float64(playResX+total)*(t-t0)/danmuShowSeconds
}
// 头像左上角的 x:比文字左边缘再往左一个「头像直径 + 间隔」。
// 由 danmuTextX 推导出来,保证两者永远同步
func danmuAvatarX(playResX, avPad, total int, t, t0 float64) float64 {
return danmuTextX(playResX, avPad, total, t, t0) - float64(avPad)
}
// 画布短边:横屏取高、竖屏取宽。弹幕文字和头像的尺寸都以它为准 ——
// 竖屏画面窄,按高度算出来的字会大到一条弹幕就铺满整行。
func shortSide(w, h int) int {
if w <= 0 || h <= 0 {
return 0
}
if w < h {
return w
}
return h
}
// 自动字号的比例:短边 × 60/1080 ≈ 5.56%,
// 和录制端的档位预设一致(1920x1080 → 60、1280x720 → 40、960x540 → 30)
const autoFontRatioNum, autoFontRatioDen = 60, 1080
// 按画布短边算自动字号,拿不到尺寸时返回 0
func autoFontSize(w, h int) int {
short := shortSide(w, h)
if short <= 0 {
return 0
}
fs := int(math.Round(float64(short) * autoFontRatioNum / autoFontRatioDen))
if fs < 1 {
fs = 1
}
return fs
}
// 定成片字号。设置里填了 >0 就按填的(成片像素);填 0(默认)按画布短边自动算,
// 这样横屏和竖屏的字看起来一样大,竖屏成片不会字大到铺满整行。头像随字号等比缩放。
func applyFontSize(meta *danmuMeta, override int) {
if override > 0 {
if override != meta.FontSize {
lPrintf("弹幕字号按设置使用 %d(弹幕文件里是 %d)", override, meta.FontSize)
}
meta.FontSize = override
return
}
auto := autoFontSize(meta.PlayResX, meta.PlayResY)
if auto <= 0 || auto == meta.FontSize {
return
}
orient := "横屏"
if meta.PlayResY > meta.PlayResX {
orient = "竖屏"
}
lPrintf("%s画布 %dx%d,弹幕字号自动取 %d(短边 × %d/%d,弹幕文件里是 %d)",
orient, meta.PlayResX, meta.PlayResY, auto, autoFontRatioNum, autoFontRatioDen, meta.FontSize)
meta.FontSize = auto
}
// 弹幕合成入口(命令行)
// 弹幕文件可以给 .jsonl,也可以给 .ass(先还原成 jsonl 再合成)
func runDanmuburn(danmuFile, videoFile, outFile string, offset float64) {
src, err := prepareDanmuSource(danmuFile, true)
if err != nil {
if err == errNoDanmu {
lPrintWarn(err.Error())
os.Exit(0)
}
lPrintErr(err)
os.Exit(1)
}
// 还原结果写不进原目录时会落在系统临时目录里,收工前删掉。
// 这里到处都是 os.Exit,defer 不会被执行,所以显式调用
if err := burnDanmu(src.jsonlPath, videoFile, outFile, offset, mergeOptions{}); err != nil {
dropTempDanmu(src)
if err == errNoDanmu {
lPrintWarn(err.Error())
os.Exit(0)
}
lPrintErr(err)
os.Exit(1)
}
dropTempDanmu(src)
}
// 按偏移算出视频时间,落在视频时长之外的丢掉;返回留下的弹幕和被丢掉的条数
func clipDanmuToVideo(records []danmuRecord, offset, videoDuration float64) ([]danmuRecord, int) {
kept := make([]danmuRecord, 0, len(records))
for _, r := range records {
if r.Time+offset < videoDuration {
kept = append(kept, r)
}
}
return kept, len(records) - len(kept)
}
// 把弹幕对齐到视频的时间轴。
// 正常情况就是「超出视频时长的部分丢掉」;但 upstream 录出来的 .ass 时间不一定相对录制起点
// (程序先开着、直播中途才开始录的场次,弹幕时间基准会比视频晚一大截),
// 那种场次会被整体判成「超出时长」然后一条都合不出来,这里按「第一条弹幕对齐视频起点」平移救回来。
// 只在「一条都用不上」且「弹幕跨度装得进视频」时才平移,本来就能合成的场次完全不受影响。
// 返回调整后的弹幕和偏移(后续渲染都用这个偏移)。
func fitDanmuToVideo(records []danmuRecord, offset, videoDuration float64) ([]danmuRecord, float64) {
kept, dropped := clipDanmuToVideo(records, offset, videoDuration)
if dropped == 0 {
return records, offset
}
if len(kept) > 0 {
// 只是一部分超出(弹幕确实录得比视频久),保持原行为:丢掉超出的那部分
lPrintWarnf("有 %d 条弹幕超出了录播视频的时长(%.1f 秒),已跳过", dropped, videoDuration)
return kept, offset
}
first, last := records[0].Time, records[0].Time
for _, r := range records[1:] {
if r.Time < first {
first = r.Time
}
if r.Time > last {
last = r.Time
}
}
late := first + offset // 最早的那条比视频起点晚多少秒(走到这里必然 ≥ 视频时长)
if last-first >= videoDuration {
// 弹幕跨度本身就装不进视频:平移也救不回来
lPrintWarnf("有 %d 条弹幕超出了录播视频的时长(%.1f 秒),已跳过", dropped, videoDuration)
return nil, offset
}
newOffset := -first
lPrintWarnf("弹幕时间基准和视频对不上:%d 条弹幕全部落在视频时长(%.1f 秒)之外,最早那条在 %.1f 秒;"+
"已按「第一条弹幕对齐视频起点」整体前移 %.1f 秒(偏移 %.1f → %.1f)",
len(records), videoDuration, late, late, offset, newOffset)
// 跨度装得进视频,所以平移之后不会再丢弹幕
kept, _ = clipDanmuToVideo(records, newOffset, videoDuration)
start, end := kept[0].Time+newOffset, kept[len(kept)-1].Time+newOffset
lPrintf("对齐后弹幕覆盖 %.1f ~ %.1f 秒,视频总长 %.1f 秒(尾部空 %.1f 秒);"+
"如果弹幕整体偏早或偏晚,在「偏移」里微调后重新合并",
start, end, videoDuration, videoDuration-end)
return kept, newOffset
}
// 合成一个 jsonl(和可选的视频)为带头像的弹幕层
// videoFile 为空时输出透明头像层(ProRes 4444),否则把弹幕层压进视频。
// opt 是这一次的合成参数,不填的项用全局设置(见 mergeOptions)
func burnDanmu(jsonlFile, videoFile, outFile string, offset float64, opt mergeOptions) error {
jsonlFile, err := filepath.Abs(jsonlFile)
if err != nil {
return err
}
if _, err := os.Stat(jsonlFile); err != nil {
return fmt.Errorf("jsonl 文件不存在:%s", jsonlFile)
}
// 读取 jsonl,首行是元数据
f, err := os.Open(jsonlFile)
if err != nil {
return err
}
defer f.Close()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
var meta danmuMeta
var records []danmuRecord
first := true
for scanner.Scan() {
line := scanner.Bytes()
if first {
if err := json.Unmarshal(line, &meta); err != nil {
return err
}
first = false
continue
}
var r danmuRecord
if json.Unmarshal(line, &r) == nil && r.Content != "" {
records = append(records, r)
}
}
if err := scanner.Err(); err != nil {
return err
}
if meta.PlayResX == 0 || meta.FontSize == 0 {
return errors.New("jsonl 文件格式不对,首行必须包含 playResX、playResY、fontSize")
}
if len(records) == 0 {
return errNoDanmu
}
// 确定输出文件和模式:有视频就把弹幕压进视频,没有视频就输出透明头像层
burnMode := videoFile != ""
if burnMode {
videoFile, err = filepath.Abs(videoFile)
if err != nil {
return err
}
if _, err := os.Stat(videoFile); err != nil {
return fmt.Errorf("录播视频不存在:%s", videoFile)
}
if outFile == "" {
// 成片默认放到单独的文件夹里,不再和录播、弹幕混在一起
outFile = filepath.Join(mergeSaveDir(), filepath.Base(strings.TrimSuffix(jsonlFile, ".jsonl")+"_弹幕合并.mp4"))
}
} else {
if outFile == "" {
outFile = strings.TrimSuffix(jsonlFile, ".jsonl") + "_avatar.mov"
}
}
outFile, err = filepath.Abs(outFile)
if err != nil {
return err
}
ffmpegFile := getFFmpeg()
if ffmpegFile == "" {
return errors.New("找不到 ffmpeg,请先安装并加入 PATH,或把 ffmpeg 放在本程序所在文件夹,或在设置里填写 ffmpeg 路径")
}
// 时长、分辨率、码率、帧率一次探测拿全(原来为了拿时长和视频流各起了一次 ffmpeg)
var vinfo mediaInfo
if burnMode {
vinfo = probeMedia(ffmpegFile, videoFile)
}
// 弹幕可能录得比视频久(视频中途中断过),超出视频时长的部分没法渲染,直接丢掉;
// 时间基准整体偏移的 .ass(见 fitDanmuToVideo)在这一步救回来
videoDuration := 0.0
if burnMode {
videoDuration = vinfo.duration
if videoDuration > 0 {
records, offset = fitDanmuToVideo(records, offset, videoDuration)
if len(records) == 0 {
return errNoDanmu
}
}
}
// 弹幕文件的坐标系和视频对不上时,统一到视频坐标系(见 unifyDanmuSpace);
// 顺便拿到源片码率(用于「合成码率填 0」时的自动码率)和帧率(用于输出帧率)。
// 码率沿用「视频流自己的,没有就用容器总码率」的老口径(见 streamOrContainerKbps)
sourceKbps := 0
sourceFps := 0.0
if burnMode {
sourceKbps, sourceFps = streamOrContainerKbps(vinfo), vinfo.fps
unifyDanmuSpace(&meta, vinfo.width, vinfo.height)
lPrintf("视频编码器优先顺序:%s", strings.Join(availableEncoders(ffmpegFile), " → "))
}
// 字号在坐标系统一之后再定,此时 meta.PlayRes 就是成片画布
applyFontSize(&meta, opt.fontSize())
// 弹道按最终字号统一重排,避免还原弹幕或改字号后同弹道追尾
relayoutLanes(records, meta)
// 缓存放在程序所在目录,换保存目录(甚至换盘)都不用重新下载;
// 旧版本放在弹幕文件同目录,作为回退来源读一次并搬过去
cacheDir := avatarCachePath()
legacyCache := filepath.Join(filepath.Dir(jsonlFile), avatarCacheDir)
// 先去重,顺便记下每个用户自带的头像链接
urlByUID := make(map[int64]string)
uids := make([]int64, 0, 64)
for _, r := range records {
if r.UID == 0 {
continue
}
if old, ok := urlByUID[r.UID]; !ok {
uids = append(uids, r.UID)
urlByUID[r.UID] = r.Avatar
} else if old == "" && r.Avatar != "" {
// 同一个用户的前几条可能没有头像链接,后面带上就补上
urlByUID[r.UID] = r.Avatar
}
}
avatarPaths, _, err := fetchAvatars(uids, urlByUID, cacheDir, legacyCache)
if err != nil {
return err
}
if len(avatarPaths) == 0 {
lPrintWarn("没有任何头像可用,弹幕将只烧录文字,请检查网络后重试合并")
}
// 合成码率:填了值就用它;填 0(默认)按「源片码率 + 弹幕余量」算,探测不到才退回质量模式
bitrate := opt.bitrate() + burnBitrate
if bitrate == 0 && burnMode {
if auto := autoMergeBitrate(sourceKbps); auto > 0 {
lPrintf("合成码率填的 0,按源片 %d kbps + 弹幕余量 %d kbps 取 %d kbps", sourceKbps, danmuBitrateMargin, auto)
bitrate = auto
} else {
lPrintWarn("探测不到源片码率,改用默认质量模式(ICQ 22 / CRF 18)")
}
}
// 输出帧率:填 0 = 跟随源片(不动)。低帧率录播(15/24fps)里弹幕是一格一格跳的,
// 提到更高帧率之后弹幕层按新帧率重绘,字和头像都顺了;源片本来就够高就不动
wantFps := opt.fps()
outFps := outputFps(wantFps, sourceFps)
if burnMode && wantFps > 0 && outFps == 0 {
lPrintf("输出帧率:源片 %.2f fps 已经不低于设置的 %d fps,不做处理", sourceFps, wantFps)
}
if outFps > 0 {
lPrintf("输出帧率:源片 %.2f fps,按设置提到 %d fps(弹幕层会按新帧率重绘)", sourceFps, outFps)
}
// 头像预缩放成最终尺寸 + 短名字的相对路径:滤镜图整段在命令行里,
// 路径长度直接决定单块能画多少条弹幕(见 prepareAvatarInputs)
workDir := filepath.Dir(jsonlFile)
avatarInputs, dropAvatarInputs, err := prepareAvatarInputs(workDir, avatarPaths, avatarSize(meta.FontSize))
if err != nil {
return err
}
defer dropAvatarInputs()
ctx := &burnContext{
ffmpegFile: ffmpegFile,
videoFile: videoFile,
outFile: outFile,
offset: offset,
meta: meta,
avatars: avatarInputs,
bitrate: bitrate,
dir: workDir,
outFps: outFps,
layerFps: layerFps(wantFps),
encoder: opt.encoder(),
}
chunks := splitDanmuChunks(records, offset, videoDuration)
// 任务总时长:有视频就用视频时长,透明头像层模式没有视频,用各块时长之和
jobTotalSec := videoDuration
if jobTotalSec <= 0 {
for _, ch := range chunks {
jobTotalSec += ctx.chunkDuration(ch)
}
}
jobSetTotal(jobTotalSec)
// 预计成片体积 =(视频码率 + 音频码率)× 时长 ÷ 8;渲染过 5% 之后会被实测外推值顶掉
if bitrate > 0 {
jobPlanSize(int64(float64(bitrate+estimateAudioKbps) * jobTotalSec / 8 * 1000))
}
jobChunks(0, len(chunks))
jobSetPhase(jobPhaseRender)
if len(chunks) <= 1 {
lPrintf("共 %d 条弹幕,%d 个用户的头像可用,开始合成", len(records), len(avatarPaths))
return ctx.burnSingle(records)
}
lPrintf("共 %d 条弹幕,%d 个用户的头像可用;超过单块绘制上限(%d 条),分 %d 块渲染后拼接",
len(records), len(avatarPaths), maxDanmuPerChunkHard, len(chunks))
return ctx.burnChunked(chunks)
}
// 获取所有用户的头像:没有自带链接的先按 uid 查一次,再决定用已有缓存还是下载。
// 之所以并发:查链接实测约 1 秒/个,几百人的场次串行做要好几分钟。
// 返回 uid → 缓存文件路径、本阶段统计;暂停时在闸门处等待,停止时返回 errJobStopped。
func fetchAvatars(uids []int64, urlByUID map[int64]string, cacheDir, legacyCache string) (map[int64]string, avatarStat, error) {
type avatarTask struct {
uid int64
url string
}
tasks := make([]avatarTask, 0, len(uids))
for _, uid := range uids {
tasks = append(tasks, avatarTask{uid: uid, url: urlByUID[uid]})
}
paths := make(map[int64]string, len(tasks))
var stat avatarStat
jobSetPhase(jobPhaseAvatar)
jobAvatars(0, len(tasks))
if len(tasks) == 0 {
return paths, stat, nil
}
lPrintf("开始获取 %d 个用户的头像(%d 路并发)", len(tasks), avatarFetchWorkers)
workers := avatarFetchWorkers
if workers > len(tasks) {
workers = len(tasks)
}
// 缓冲足够大:worker 因停止提前退出时,投递端也不会卡住
jobs := make(chan avatarTask, len(tasks))
for _, t := range tasks {
jobs <- t
}
close(jobs)
var (
mu sync.Mutex
stopErr error
)
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for t := range jobs {
// 每个任务开头过闸门:暂停时在这里等,停止时直接收工
if err := jobGate(); err != nil {
mu.Lock()
if stopErr == nil {
stopErr = err
}
mu.Unlock()
return
}
url, fetched := t.url, false
if url == "" {
// 从 .ass 还原出来的弹幕没有头像链接,按 uid 查一次(结果会落盘)
url, fetched = avatarURLByUID(t.uid, cacheDir)
}
// 下载前先看这个头像在不在,用来区分「复用缓存」和「真的下载了」
existed := url != "" && fileExists(filepath.Join(cacheDir, avatarCacheName(t.uid, url)))
path := ""
if url != "" {
path = ensureAvatar(t.uid, url, cacheDir, legacyCache)
}
mu.Lock()
stat.done++
if fetched {
stat.fetched++
} else if t.url == "" {
stat.reusedLink++
}
switch {
case existed:
stat.imgReused++
case path != "":
stat.imgDownloaded++
case url != "":
stat.failed++
}
if path != "" {
paths[t.uid] = path
}
done := stat.done
mu.Unlock()
jobAvatars(done, len(tasks))
}
}()
}
wg.Wait()
if stopErr != nil {
return paths, stat, stopErr
}
summary := fmt.Sprintf("头像处理完成:%d 个用户,链接复用 %d / 向 AcFun 查 %d,图片复用缓存 %d / 新下载 %d",
len(tasks), stat.reusedLink, stat.fetched, stat.imgReused, stat.imgDownloaded)
if stat.failed > 0 {
summary += fmt.Sprintf(",%d 个不可用(这些用户只烧文字)", stat.failed)
}
lPrintf("%s", summary)
return paths, stat, nil
}
// 渲染前按最终字号重排弹道:jsonl 里的 lane/width 是录制或 .ass 还原时算的,
// 从原格式 .ass 还原的弹幕没把昵称和头像算进宽度(占位提前放行了同弹道的下一条,
// 画面上就追尾叠字),改过字号后更是全对不上。这里用和录制时相同的碰撞公式、
// 按「昵称(uid):内容 + 头像」的完整宽度重新分配一遍,保证弹道不重叠。
func relayoutLanes(records []danmuRecord, meta danmuMeta) {
sort.SliceStable(records, func(i, j int) bool { return records[i].Time < records[j].Time })
fs := meta.FontSize
show := int64(danmuShowSeconds * 1e9)
resX := int64(meta.PlayResX)
avW, avGap := avatarSize(fs), avatarGap(fs)
// 空弹道用「远古」状态占位:placeLane 的条件是本条出现时间晚于上一条的让位时刻,
// 零值下 0 > 0 不成立,第一条弹幕(还原出来的弹幕时间可能正好是 0)会占不上弹道,
// 占用状态丢掉后后面的弹幕又会跟着落回空弹道,等于白排。录制时弹幕时间总大于 0,
// 所以原逻辑没暴露这个问题。
lanes := make([]laneState, laneNum)
for i := range lanes {
lanes[i] = laneState{appear: math.MinInt64, emerge: math.MinInt64, disappear: math.MinInt64}
}
for i := range records {
r := &records[i]
r.Width = textWidth(speakerText(r.Name, r.UID, r.Content), fs)
total := int64(avW+avGap) + int64(r.Width)
rel := int64(r.Time * 1e9)
dt := laneState{
appear: rel,
emerge: rel + total*show/(resX+total),
disappear: rel + show,
}
left := rel + resX*show/(resX+total)
j := placeLane(lanes, dt, left)
if j < 0 {
// 100 条弹道都还占着:退到最早让出来的那条。录制时是直接不写这条,
// 合并时丢一条等于凭空少一句弹幕,压一点也比丢了强。
j = earliestLane(lanes)
lanes[j] = dt
}
r.Lane = j
}
}
// 最早会空出来的弹道行
func earliestLane(lanes []laneState) int {
best := 0
for i := range lanes {
if lanes[i].disappear < lanes[best].disappear {
best = i
}
}
return best
}
// 把渲染坐标系统一到视频分辨率。
// 弹幕文件里的 PlayResX/PlayResY 是录制当时的画面尺寸,老文件常和现在的视频对不上
// (例如 960x540 的弹幕文件配 720x1280 的竖屏视频)。libass 会按「视频高 / PlayResY」
// 缩放文字和它的坐标,而头像叠加用的是视频像素坐标,两者不在同一个坐标系里 ——
// 字号越大越明显:头像显得小,而且每往下一行错得越多(看起来和文字分开了)。
// 这里只负责改 PlayRes,字号交给 applyFontSize 按最终画布重算。返回是否做过调整。
func unifyDanmuSpace(meta *danmuMeta, vw, vh int) bool {
if vw <= 0 || vh <= 0 || (vw == meta.PlayResX && vh == meta.PlayResY) {
return false
}
oldX, oldY := meta.PlayResX, meta.PlayResY
meta.PlayResX, meta.PlayResY = vw, vh
lPrintf("弹幕文件的坐标系是 %dx%d,视频是 %dx%d,改按视频坐标系渲染", oldX, oldY, vw, vh)
return true
}
// 视频流的关键参数。拿不到的项一律为 0
type videoStreamInfo struct {
width int
height int
kbps int // 视频码率(Kbps),流里没写时退回容器总码率
fps float64 // 帧率
}
// 探测视频流的分辨率、码率和帧率。
// 实际解析在 transcode.go 的 parseMediaInfo 里(一次拿全时长/画面/音轨),这里只挑视频那几个字段
func probeVideoStream(ffmpegFile, path string) videoStreamInfo {
m := probeMedia(ffmpegFile, path)
// 口径跟以前一致:视频流自己没写码率就退回容器总码率,别改成扣掉音频的版本
return videoStreamInfo{width: m.width, height: m.height, kbps: streamOrContainerKbps(m), fps: m.fps}
}
// 合成码率填 0(默认)时的算法:源片码率 + 弹幕余量。
// 烧进去的文字和头像都是高频细节,跟源片同样码率会糊,所以留一点余量;
// 探测不到源片码率时返回 0,让调用方退回质量模式。
func autoMergeBitrate(sourceKbps int) int {
if sourceKbps <= 0 {
return 0
}
br := sourceKbps + danmuBitrateMargin
if br < minMergeBitrate {
br = minMergeBitrate
}
return br
}
const (
// 弹幕层占用的额外码率(Kbps)
danmuBitrateMargin = 500
// 自动码率的下限,源片码率特别低时也别低于这个值
minMergeBitrate = 800
)
// 用 ffmpeg 探测媒体文件的时长(秒),探测不到返回 0。
// 解析在 transcode.go 的 parseMediaInfo 里(一次拿全时长/画面/音轨),这里保留薄封装给老调用点
func probeDuration(ffmpegFile, path string) float64 {
return probeMedia(ffmpegFile, path).duration
}
// 弹幕不多时直接一次渲染完,同样从 jsonl 生成字幕,不依赖录制时留下的 .nick.ass
func (c *burnContext) burnSingle(records []danmuRecord) error {
chunk := danmuChunk{start: 0, end: math.Inf(1), records: records}
// 临时字幕一律用纯 ASCII 文件名:libass 在部分环境下打不开非 ASCII 的文件名
assName := chunkTempPrefix + "single.ass"
assPath := filepath.Join(c.dir, assName)
if err := writeChunkAss(assPath, c.meta, chunk, c.offset,
avatarSize(c.meta.FontSize), avatarGap(c.meta.FontSize)); err != nil {
return fmt.Errorf("生成字幕文件失败:%v", err)
}
defer os.Remove(assPath)
// 只有一块,ffmpeg 报的时长就是整片进度
return c.renderChunk(chunk, assName, c.outFile, false, func(outSec, speed, fps float64, size int64) {
jobRender(outSec, speed, fps, size)
})
}
// 分块渲染:每块单独渲染成一个小片,最后用 concat 拼成完整成片
func (c *burnContext) burnChunked(chunks []danmuChunk) error {
ext := ".mov"
if c.videoFile != "" {
ext = ".mp4"
}
var parts []string
defer func() {
for _, p := range parts {
_ = os.Remove(p)
}
}()
ad, gap := avatarSize(c.meta.FontSize), avatarGap(c.meta.FontSize)
// 已完成块写出的字节数:加在当前块上,界面上的「已写出」和体积外推才是累计值
var bytesBase int64
var joinedSec float64
for _, ch := range chunks {
joinedSec += c.chunkDuration(ch)
}
for i, ch := range chunks {
// 每块开始时过一下闸门:暂停在这里等,停止在这里直接退出
if err := jobGate(); err != nil {
return err
}
jobChunks(i, len(chunks))
// 每块的字幕单独生成,只包含这一块时间范围内的弹幕
assName := fmt.Sprintf("%schunk_%04d.ass", chunkTempPrefix, i)
assPath := filepath.Join(c.dir, assName)
if err := writeChunkAss(assPath, c.meta, ch, c.offset, ad, gap); err != nil {
return fmt.Errorf("生成第 %d 块的字幕失败:%v", i+1, err)
}
defer os.Remove(assPath)
part := filepath.Join(c.dir, fmt.Sprintf("%spart_%04d%s", chunkTempPrefix, i, ext))
// 先登记再渲染:中途失败或被打断时,defer 里的清理也能把这个半成品删掉
parts = append(parts, part)
lPrintf("正在渲染第 %d/%d 块(%s ~ %s,共画 %d 条,其中续画 %d 条)",
i+1, len(chunks), formatSeconds(ch.start), formatSeconds(ch.end), len(ch.records), ch.carried)
base := bytesBase
if err := c.renderChunk(ch, assName, part, true, func(outSec, speed, fps float64, size int64) {
// 块内时间加上块起点,折算成整片时间轴上的进度
jobRender(ch.start+outSec, speed, fps, base+size)
}); err != nil {
if jobStopRequested() {
return errJobStopped
}
return fmt.Errorf("渲染第 %d/%d 块失败:%v", i+1, len(chunks), err)
}
if n := fileSizeOrZero(part); n > 0 {
bytesBase += n
}
}
jobSetPhase(jobPhasePlace)
lPrintf("正在把 %d 个分块拼接成完整成片", len(parts))
if err := concatChunks(c.ffmpegFile, parts, c.outFile, c.dir, func(outSec, speed, fps float64, size int64) {
if joinedSec > 0 {
jobSetPhasePercent(outSec / joinedSec)
}
}); err != nil {
return err
}
return nil
}
// 把弹幕按时间切成若干块,块的时间范围首尾相接,保证视频不会缺帧。
//
// 每块画两组东西:
// 1. 「续画」:上一块尾还在屏上的弹幕。一条弹幕停留 10 秒,块边界经常从它中间切过去。
// 不续画的话,每个块头屏幕上就只剩刚出现的那几条,要 10 秒才重新填满 ——
// 看起来就是「弹幕突然加速,然后一段时间屏幕上没有弹幕」;
// 2. 起播时间落在本块里的弹幕。
//
// 两组合起来不能超过 maxDanmuPerChunkHard(命令行长度限制),所以块的长度是
// 「预算 ÷ 弹幕密度」自己算出来的。续画把预算吃光时挤掉最早的那几条
// (它们已经滚到左边缘快出画了),但每块一定留 minNewDanmuPerChunk 条新弹幕 ——
// 否则块长会退化成 0,无限细分下去。
//
// 块尾统一取「下一条弹幕的起播时间」:这样每条弹幕只会在唯一一块里算「新出现」,
// 而它显示期间跨过的后面几块会把它当续画接着画,一帧都不缺。
// maxEnd 大于 0 时表示视频总时长,块尾不会超过它
func splitDanmuChunks(records []danmuRecord, offset, maxEnd float64) []danmuChunk {
n := len(records)
if n == 0 {
return nil
}
if n <= maxDanmuPerChunk {
return []danmuChunk{{start: 0, end: math.Inf(1), records: records}}
}
appear := func(i int) float64 { return records[i].Time + offset }
chunks := make([]danmuChunk, 0, n/maxDanmuPerChunk+1)
// records[:next] 都已经在更早的块里「新出现」过了
next := 0
start := 0.0
for next < n {
// 续画:起播时间在 (start-10, start) 的弹幕,此刻都还在屏上
from := next
for from > 0 && appear(from-1) > start-danmuShowSeconds {
from--
}
carried := records[from:next]
room := maxDanmuPerChunkHard - len(carried)
if room < minNewDanmuPerChunk {
// 爆点:续画快把预算占满了。挤掉最早的那几条(已经快到左边缘),
// 换出空间放新弹幕,同时保证块一定往前走
drop := minNewDanmuPerChunk - room
if drop > len(carried) {
drop = len(carried)
}
carried = carried[drop:]
room = minNewDanmuPerChunk
}
hi := next + room
if hi > n {
hi = n
}
end := math.Inf(1)
if hi < n {
end = appear(hi)
if maxEnd > 0 && end > maxEnd {
end = maxEnd
}
if end <= start {
// 同一时刻挤了一堆弹幕(时间戳重复)时块尾会和块头重合,那种块
// 渲染出来一帧都没有。往后挪一点,被跳过的弹幕下一块会当续画接着画
end = start + minChunkSpan
}
}
recs := make([]danmuRecord, 0, len(carried)+hi-next)
recs = append(recs, carried...)
recs = append(recs, records[next:hi]...)
chunks = append(chunks, danmuChunk{start: start, end: end, carried: len(carried), records: recs})
next = hi
if math.IsInf(end, 1) {
break
}
start = end
}
return chunks
}
// 块尾和块头挨得太近时至少拉开的时长。太短的话这一块渲染出来只有 0~1 帧,拼进成片会缺帧
const minChunkSpan = 0.5
// 为分块渲染生成只包含该块弹幕的字幕文件(相对块起点计时)
func writeChunkAss(path string, meta danmuMeta, chunk danmuChunk, offset float64, avW, avGap int) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
// ASS 必须带 [Script Info] 开头,否则 libass 探测格式会失败
fmt.Fprintf(f, scriptInfoTmpl, "", "", filepath.Base(path), meta.PlayResX, meta.PlayResY)
fmt.Fprintf(f, stylesTmpl, meta.FontSize)
fmt.Fprint(f, eventsTmpl)
for _, r := range chunk.records {
visStart, visEnd, visible := danmuVisibleInChunk(r, offset, chunk)
if !visible {
continue
}
text := r.Name + "(" + strconv.FormatInt(r.UID, 10) + "):" + r.Content
y := meta.FontSize * r.Lane
// 起止位置按**绝对时间**算:被块边界截断的弹幕从上一块的落点接着往下走,
// 速度和行程都不变,只是这一块少画一段。以前这里直接拿「截断后的时长」
// 当整段行程(End 用 visEnd、x2 写死 -Width),等于把 10 秒的路挤进
// 零点几秒里,文字就飞过去了 —— 而头像 overlay 还是按 10 秒走,
// 于是「头像中途掉队」(见 danmuTextX 的说明)
t0 := r.Time + offset
total := avW + avGap + r.Width
x1 := int(math.Round(danmuTextX(meta.PlayResX, avW+avGap, total, visStart, t0)))
x2 := int(math.Round(danmuTextX(meta.PlayResX, avW+avGap, total, visEnd, t0)))
fmt.Fprintf(f, dialogueNickTmpl,
assTime(int64((visStart-chunk.start)*1e9)), assTime(int64((visEnd-chunk.start)*1e9)),
strings.ReplaceAll(r.Name, ",", " "), r.UID,
x1, y, x2, y, text)
}
return nil
}
// 头像左上角 x 的 ffmpeg 表达式(t 是块内时间、shift = 块起点 - t0)。
// 代入 t_绝对 = t + 块起点 就是 danmuAvatarX,系数必须和它一致
func avatarXExpr(playResX, total int, shift float64) string {
return fmt.Sprintf("%d-(%d+%d)*(t%+.3f)/%g", playResX, playResX, total, shift, danmuShowSeconds)
}
// 一条弹幕在这一块里到底会不会被画出来:超出块的部分会被裁掉,裁完没有可见区间就整条跳过。
// 统计头像用量和生成 overlay 必须共用这一个判断 —— 只统计、不裁剪的话,同一个人在这块里
// 会被 split 出多余的份数,那些输出 pad 没人接收,ffmpeg 直接以 EINVAL 退出
// (Windows 上就是 exit status 0xffffffea,报错完全看不出跟弹幕有关,
// 还会连累三个编码器挨个"运行失败")
func danmuVisibleInChunk(r danmuRecord, offset float64, chunk danmuChunk) (visStart, visEnd float64, ok bool) {
t0 := r.Time + offset
visStart = math.Max(t0, chunk.start)
visEnd = math.Min(t0+danmuShowSeconds, chunk.end)
return visStart, visEnd, visEnd > visStart
}
// 渲染一个块:把这一块的弹幕(文字层 + 头像层)压进对应的视频片段
// cut 为 true 时表示这是分块渲染,需要按时长切出视频片段并重编码音频
// cb 用来回传 ffmpeg 的实时进度(已输出时长 / 倍速 / 帧率 / 已写出字节),可为 nil
func (c *burnContext) renderChunk(chunk danmuChunk, assBase, out string, cut bool, cb ffmpegProgress) error {
burnMode := c.videoFile != ""
// 统计这一块用到的头像,按首次出现顺序分配输入序号(输入 0 是视频或背景)。
// 只算真正会画出来的弹幕,见 danmuVisibleInChunk 的说明
uidOrder := []int64{}
uidCount := make(map[int64]int)
uidInput := make(map[int64]int)
for _, r := range chunk.records {
if _, ok := c.avatars[r.UID]; !ok {
continue
}
if _, _, visible := danmuVisibleInChunk(r, c.offset, chunk); !visible {
continue
}
if _, seen := uidCount[r.UID]; !seen {
uidOrder = append(uidOrder, r.UID)
uidInput[r.UID] = len(uidOrder)
}
uidCount[r.UID]++
}
args := []string{"-y"}
if burnMode {
if cut {
// -ss 放在 -i 之前是快速定位,配合重编码能精确切到指定时间点
args = append(args, "-ss", formatSeconds(chunk.start))
}
args = append(args, "-i", c.videoFile)
} else {
args = append(args, "-f", "lavfi", "-i",
fmt.Sprintf("color=c=black@0:s=%dx%d:d=%.2f:r=%d,format=rgba",
c.meta.PlayResX, c.meta.PlayResY, c.chunkDuration(chunk)+0.5, c.layerRate()))
}
for _, uid := range uidOrder {
args = append(args, "-i", c.avatars[uid])
}
fc := &strings.Builder{}
prev := "0:v"
// 低帧率源先提到目标帧率(重复帧):字幕和头像都在这一步之后渲染,弹幕才会
// 按下发的新帧率一格格重画。放在字幕之后加等于没做 —— 弹幕已经按源片帧率
// 画好了,再复制帧还是原来那几个位置
if burnMode && c.outFps > 0 {
fmt.Fprintf(fc, "[0:v]fps=%d[fps0];\n", c.outFps)
prev = "fps0"
}
if burnMode {
fmt.Fprintf(fc, "[%s]subtitles=filename='%s'[b0];\n", prev, assBase)
prev = "b0"
}
// 头像已经预缩放成 ad×ad(见 prepareAvatarInputs),这里直接用输入 pad;
// 只有同一个人的头像在这一块里要用到多条弹幕时才 split 复制
ad := avatarSize(c.meta.FontSize)
gap := avatarGap(c.meta.FontSize)
for _, uid := range uidOrder {
if uidCount[uid] <= 1 {
continue
}
idx := uidInput[uid]
outs := &strings.Builder{}
for j := 0; j < uidCount[uid]; j++ {
fmt.Fprintf(outs, "[av%d_%d]", idx, j)
}
fmt.Fprintf(fc, "[%d:v]split=%d%s;\n", idx, uidCount[uid], outs.String())
}
uidCopy := make(map[int64]int) // 每个头像当前用掉的份数
for n, r := range chunk.records {
idx, ok := uidInput[r.UID]
if !ok {
continue
}
// 先判可见性再取 split 的份:被裁掉的弹幕不能占掉一份,
// 否则份数对不上,split 会留下没人接收的输出 pad(见 danmuVisibleInChunk)
visStart, visEnd, visible := danmuVisibleInChunk(r, c.offset, chunk)
if !visible {
continue
}
avPad := strconv.Itoa(idx) + ":v"
if uidCount[r.UID] > 1 {
avPad = "av" + strconv.Itoa(idx) + "_" + strconv.Itoa(uidCopy[r.UID])
uidCopy[r.UID]++
}
t0 := r.Time + c.offset
// 头像在文字左侧并留出间隔:ASS 里的文字左边缘是这里加 ad+gap,头像跟着文字一起滚动。
// 公式与文字层的 danmuTextX 同源(见 avatarXExpr):这里 t 是块内时间,
// 把 t0 也换算到块内坐标,两边在任意时刻算出来的位置都严丝合缝
total := ad + gap + r.Width
x := avatarXExpr(c.meta.PlayResX, total, chunk.start-t0)
y := c.meta.FontSize*r.Lane + (c.meta.FontSize-ad)/2 // 头像与文字墨迹垂直居中
outLabel := fmt.Sprintf("s%d", n)
fmt.Fprintf(fc, "[%s][%s]overlay=x='%s':y=%d:enable='between(t,%.3f,%.3f)'[%s];\n",
prev, avPad, x, y, visStart-chunk.start, visEnd-chunk.start, outLabel)
prev = outLabel
}
if burnMode {
fmt.Fprintf(fc, "[%s]format=yuv420p[v]", prev)
} else {
fmt.Fprintf(fc, "[%s]format=yuva444p10le[v]", prev)
}
args = append(args, "-filter_complex", fc.String(), "-map", "[v]")
if burnMode {
args = append(args, "-map", "0:a?")
if cut {
// 分块切割点的音频不在帧边界上,必须重编码,否则拼接后会累积音画错位
args = append(args, "-c:a", "aac", "-b:a", "192k")
} else {
args = append(args, "-c:a", "copy")
}
args = append(args, "-movflags", "+faststart")
} else {
args = append(args, "-c:v", "prores_ks", "-profile:v", "4444", "-pix_fmt", "yuva444p10le")
}
if cut && !math.IsInf(chunk.end, 1) {
args = append(args, "-t", formatSeconds(chunk.end-chunk.start))
}
// 自检:每条弹幕都要生成一段 overlay,条数一多整个命令行就会顶破 Windows 的
// 32767 字符上限,而 exec 只会回一句 "The filename or extension is too long",
// 完全看不出跟弹幕数量有关(这个构建的 ffmpeg 也没有 -filter_complex_script 可以绕)。
// 这里按实际参数估长,超了就给一句能定位的错,而不是让它烂在 exec 里
if n := commandLength(c.ffmpegFile, args); n > maxCommandLineLen {
return fmt.Errorf("ffmpeg 命令过长(%d 字符,Windows 上限 %d 左右):这一块有 %d 条弹幕、%d 个头像,请调小单块弹幕数",
n, maxCommandLineLen, len(chunk.records), len(uidOrder))
}
if !burnMode {
// 透明头像层模式固定用 ProRes 4444,不能再追加编码器覆盖
full := append(append([]string{}, args...), out)
lPrintln("开始运行 ffmpeg 合成透明头像层,输出到 " + out)
if err := runFFmpegWith(c.ffmpegFile, full, c.dir, cb); err != nil {
return err
}
return nil
}
// 烧录模式:优先 Intel QSV 硬件编码,运行失败自动回退 libx264
encoders := buildEncoders(c.bitrate, c.ffmpegFile)
// 已经在这条流水线里验证过可用的编码器直接复用,分块时不用每块都回退一次
begin := 0
if c.encoder != "" {
for i, enc := range encoders {
if enc.name == c.encoder {
begin = i
break
}
}
}
var lastErr error
for i := begin; i < len(encoders); i++ {
enc := encoders[i]
full := append(append([]string{}, args...), "-c:v", enc.name)
full = append(full, enc.opts...)
full = append(full, out)
if i == begin {
lPrintf("开始运行 ffmpeg 合成(编码器 %s),输出到 %s", enc.name, out)
}
if err := runFFmpegWith(c.ffmpegFile, full, c.dir, cb); err != nil {
// 停止任务时杀掉 ffmpeg 也会让它非 0 退出,这时候不该再换编码器重试
if jobStopRequested() {
return errJobStopped
}
lastErr = err
if i+1 < len(encoders) && encoderMightHelp(err) {
lPrintWarnf("编码器 %s 运行失败,回退到 %s:%v", enc.name, encoders[i+1].name, err)
continue
}
// 跟编码器无关的失败(滤镜图建不起来、输入打不开等)换个编码器也是一样的结果,
// 直接把 ffmpeg 的报错写进日志,别让它烂在 exec 的一句 exit status 里
lPrintErrf("渲染失败:%v", err)
return fmt.Errorf("ffmpeg 运行失败:%v", err)
}
c.encoder = enc.name
return nil
}
return fmt.Errorf("ffmpeg 运行失败:%v", lastErr)
}
// 这一块需要渲染的时长(用于透明头像层模式生成等长的黑底)
func (c *burnContext) chunkDuration(chunk danmuChunk) float64 {
if !math.IsInf(chunk.end, 1) {
return chunk.end - chunk.start
}
maxT := chunk.start
for _, r := range chunk.records {
if t := r.Time + c.offset + danmuShowSeconds; t > maxT {
maxT = t
}
}
return maxT - chunk.start
}
// 合成用的视频编码器配置
type videoEncoder struct {
name string
opts []string
}
// 编码器候选顺序:硬件 HEVC(同画质体积最省)→ 硬件 H.264 → CPU 软编
func encoderCandidates(hasHEVCQSV, hasH264QSV bool) []string {
names := make([]string, 0, 3)
if hasHEVCQSV {
names = append(names, "hevc_qsv")
}
if hasH264QSV {
names = append(names, "h264_qsv")
}
return append(names, "libx264")
}
// 本机可用的编码器候选,顺序即优先级
func availableEncoders(ffmpegFile string) []string {
return encoderCandidates(qsvEncoderAvailable(ffmpegFile, "hevc_qsv"),
qsvEncoderAvailable(ffmpegFile, "h264_qsv"))
}
// 某个编码器的输出参数。bitrate 为 0 时用质量模式(ICQ / CRF)
func encoderOpts(encoder string, bitrate int) []string {
var opts []string
isQSV := strings.HasSuffix(encoder, "_qsv")
if bitrate > 0 {
opts = []string{"-b:v", strconv.Itoa(bitrate) + "k"}
if !isQSV {
opts = append(opts, "-preset", "medium")
}
} else if isQSV {
opts = []string{"-global_quality", "22", "-preset", "medium"}
} else {
opts = []string{"-crf", "18", "-preset", "medium"}
}
// HEVC 在 mp4 里默认写成 hev1,Apple 系(QuickTime / Safari / iPhone)不认,统一用 hvc1
if isHEVCCodec(encoder) {
opts = append(opts, "-tag:v", "hvc1")
}
return opts
}
// 这个编码器是不是 HEVC(hevc_qsv / libx265 / hevc_nvenc 之类)
func isHEVCCodec(encoder string) bool {
return strings.Contains(encoder, "hevc") || strings.Contains(encoder, "265")
}
// 按目标码率构造编码器候选列表(顺序即回退顺序)
func buildEncoders(bitrate int, ffmpegFile string) []videoEncoder {
names := availableEncoders(ffmpegFile)
encoders := make([]videoEncoder, 0, len(names))
for _, name := range names {
encoders = append(encoders, videoEncoder{name: name, opts: encoderOpts(name, bitrate)})
}
return encoders
}
// ffmpeg 的进度回调:已输出时长(秒)、实时倍速、帧率、已写出字节
type ffmpegProgress func(outSec, speed, fps float64, totalSize int64)
// 跑一次 ffmpeg。cb 不为空时加 -progress pipe:1 取结构化进度;
// ffmpeg 自己的统计行仍在 stderr 里,照旧进日志,单独用命令行时不会少东西。
func runFFmpegWith(ffmpegFile string, args []string, dir string, cb ffmpegProgress) error {
// 暂停时在这里等,任务被停止时直接返回,不再启动新进程
if err := jobGate(); err != nil {
return err
}
full := args
if cb != nil {
full = append([]string{"-progress", "pipe:1"}, args...)
}
cmd := exec.Command(ffmpegFile, full...)
cmd.Dir = dir
// 一边原样透传到控制台,一边留一份尾部:服务模式下 os.Stderr 没人看,
// 失败时日志里只剩一句 "exit status 0xffffffea",谁也查不出 ffmpeg 在抱怨什么
stderr := newTailWriter(ffmpegStderrKeep)
cmd.Stderr = io.MultiWriter(os.Stderr, stderr)
if cb != nil {
// StdoutPipe 要求 Stdout 还没被设置,所以这里不能先指到 os.Stdout
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
jobAttach(cmd)
defer jobDetach(cmd)
// 读到 EOF(进程退出会关闭管道)再 Wait,避免和 Wait 抢着关管道
scanFFmpegProgress(stdout, cb)
return ffmpegResult(cmd.Wait(), stderr.String())
}
cmd.Stdout = os.Stdout
if err := cmd.Start(); err != nil {
return err
}
jobAttach(cmd)
defer jobDetach(cmd)
return ffmpegResult(cmd.Wait(), stderr.String())
}
// ffmpeg 的 stderr 尾部保留多少行:失败时够说明问题,又不至于把日志刷爆
const ffmpegStderrKeep = 40
// 只留最近若干行的 writer,给 ffmpeg 的 stderr 用(它会不停地刷进度行)
type tailWriter struct {
mu sync.Mutex
lines []string
buf []byte
keep int
}
func newTailWriter(keep int) *tailWriter {
return &tailWriter{keep: keep}
}
func (w *tailWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
for _, b := range p {
// ffmpeg 的进度行用 \r 覆盖,这里把 \r 也当行尾处理
if b == '\n' || b == '\r' {
w.flushLine()
continue
}
w.buf = append(w.buf, b)
}
return len(p), nil
}
func (w *tailWriter) flushLine() {
if len(bytes.TrimSpace(w.buf)) > 0 && !ffmpegNoise(string(w.buf)) {
w.lines = append(w.lines, string(w.buf))
if len(w.lines) > w.keep {
w.lines = w.lines[1:]
}
}
w.buf = w.buf[:0]
}
// ffmpeg 每次启动都要打一段版本和编译配置(configuration 那行有一千多字符),
// 留着它就把真正有用的报错挤出日志了
var ffmpegNoisePrefix = []string{
"ffmpeg version ", " built with ", " configuration:", " libav", " libsw",
}
func ffmpegNoise(line string) bool {
for _, p := range ffmpegNoisePrefix {
if strings.HasPrefix(line, p) {
return true
}
}
return false
}
// 尾部内容(含还没有换行的最后一段)
func (w *tailWriter) String() string {
w.mu.Lock()
defer w.mu.Unlock()
lines := append([]string{}, w.lines...)
if len(bytes.TrimSpace(w.buf)) > 0 {
lines = append(lines, string(w.buf))
}
return strings.Join(lines, "\n")
}
// 把 ffmpeg 自己的报错附在错误上:不然只剩一句 exit status,查不出原因
func ffmpegResult(err error, stderr string) error {
if err == nil {
return nil
}
if msg := strings.TrimSpace(stderr); msg != "" {
return fmt.Errorf("%v\n--- ffmpeg 最后的输出 ---\n%s", err, msg)
}
return err
}
// 这些报错说明问题在滤镜图或输入上,跟用哪个编码器无关:
// 挨个回退只会把同一件事重跑三遍,日志里刷三行、浪费时间还看不出区别
var notEncoderFault = []string{
"Error binding filtergraph",
"Error initializing filtergraph",
"Error reinitializing filters",
"Error parsing filterchain",
"Error opening input",
"Invalid data found when processing input",
}
// 换编码器值不值得:只有跟编码器本身有关的失败才值得回退
func encoderMightHelp(err error) bool {
msg := err.Error()
for _, s := range notEncoderFault {
if strings.Contains(msg, s) {
return false
}
}
return true
}
// 解析 -progress pipe:1 的输出:每行一个 key=value,
// 每个块以 progress=continue / progress=end 收尾,就在这时候回调一次。
func scanFFmpegProgress(r io.Reader, cb ffmpegProgress) {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 4096), 1<<20)
var doneSec, speed, fps float64
var size int64
for sc.Scan() {
key, val, ok := strings.Cut(strings.TrimSpace(sc.Text()), "=")
if !ok {
continue
}
switch key {
case "out_time_us", "out_time_ms":
// 这两个键都是微秒(ffmpeg 的历史遗留),都认一下,谁在后面谁说了算
if us, err := strconv.ParseInt(val, 10, 64); err == nil && us >= 0 {
doneSec = float64(us) / 1e6
}
case "speed":
speed = parseFFmpegSpeed(val)
case "fps":
if v, err := strconv.ParseFloat(val, 64); err == nil && v >= 0 {
fps = v
}
case "total_size":
if v, err := strconv.ParseInt(val, 10, 64); err == nil && v >= 0 {
size = v
}
case "progress":
cb(doneSec, speed, fps, size)
}
}
}
// 解析 ffmpeg 的 speed 字段,形如 "1.37x";还没开始或暂停时是 "N/A"
func parseFFmpegSpeed(v string) float64 {
v = strings.TrimSuffix(strings.TrimSpace(v), "x")
f, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
if err != nil || f < 0 {
return 0
}
return f
}
// 用 concat demuxer 把各分块的成片拼接起来。cb 用来回传拼接进度,可为 nil
func concatChunks(ffmpegFile string, files []string, out, dir string, cb ffmpegProgress) error {
listFile := filepath.Join(dir, chunkTempPrefix+"list.txt")
var b strings.Builder
for _, p := range files {
// concat demuxer 里单引号要写成 '\''
p = filepath.ToSlash(p)
b.WriteString("file '" + strings.ReplaceAll(p, "'", `'\''`) + "'\n")
}
if err := os.WriteFile(listFile, []byte(b.String()), 0644); err != nil {
return fmt.Errorf("写入拼接清单失败:%v", err)
}
defer os.Remove(listFile)
if err := runFFmpegWith(ffmpegFile, []string{"-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c", "copy", out}, dir, cb); err != nil {
// 停止任务时杀掉 ffmpeg 也会让它非 0 退出,这是「已停止」不是「拼接失败」
if jobStopRequested() {
return errJobStopped
}
return fmt.Errorf("拼接各分块失败:%v", err)
}
return nil
}
// 取文件大小,出错时返回 0
func fileSizeOrZero(path string) int64 {
if info, err := os.Stat(path); err == nil {
return info.Size()
}
return 0
}
// 文件存不存在(只关心存在性,不关心是不是目录)
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// 把秒格式化成 ffmpeg 参数,保留毫秒
func formatSeconds(sec float64) string {
return strconv.FormatFloat(sec, 'f', 3, 64)
}
// 头像缓存的文件名:uid + 头像链接的摘要。链接变了文件名跟着变,
// 主播换头像后不会一直用着旧缓存
func avatarCacheName(uid int64, url string) string {
sum := sha1.Sum([]byte(url))
return fmt.Sprintf("%d_%x.png", uid, sum[:6])
}
// 缓存的头像是不是一个完整的 PNG:PNG 必须以 IEND 块收尾。
// 只看「文件在不在」是不够的 —— 下载中断、写盘失败都会留下半截文件,
// 而它下次会被当成可用缓存塞给 ffmpeg 当输入,那一块直接渲染失败,
// 报的还是 exit status 0xffffffea 这类完全看不出跟头像有关的信息
func validCachedAvatar(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
info, err := f.Stat()
if err != nil || info.Size() < 16 {
return false
}
tail := make([]byte, 12) // IEND 块:4 字节长度 + "IEND" + 4 字节 CRC
if _, err := f.ReadAt(tail, info.Size()-12); err != nil {
return false
}
return bytes.Equal(tail[4:8], []byte("IEND"))
}
// 下载头像并圆形裁切,失败自动重试,返回缓存的 PNG 路径,失败返回空字符串。
// legacyDirs 是旧版本放缓存的位置,命中就搬过来用,省得换保存目录后重新下一次。
func ensureAvatar(uid int64, url, cacheDir string, legacyDirs ...string) string {
name := avatarCacheName(uid, url)
path := filepath.Join(cacheDir, name)
if validCachedAvatar(path) {
return path
}
if err := os.MkdirAll(cacheDir, 0755); err != nil {
lPrintWarnf("创建头像缓存文件夹 %s 失败,弹幕只烧录文字不带头像:%v", cacheDir, err)
return ""
}
// 旧位置(以前存在录播目录里)有同一个头像就直接搬过来,不重复下载
for _, dir := range legacyDirs {
if dir == "" || dir == cacheDir {
continue
}
old := filepath.Join(dir, name)
if _, err := os.Stat(old); err != nil {
continue
}
if err := os.Rename(old, path); err == nil {
if validCachedAvatar(path) {
return path
}
_ = os.Remove(path) // 旧的也是半截文件,删掉重新下
break
}
// 跨盘改名会失败,退回复制
if copyFile(old, path) == nil {
if validCachedAvatar(path) {
_ = os.Remove(old)
return path
}
_ = os.Remove(path)
break
}
}
// 清掉这个用户已经失效的旧头像
for _, dir := range append([]string{cacheDir}, legacyDirs...) {
if dir == "" {
continue
}
olds, err := filepath.Glob(filepath.Join(dir, strconv.FormatInt(uid, 10)+"_*.png"))
if err != nil {
continue
}
for _, p := range olds {
if p != path {
_ = os.Remove(p)
}
}
}
err := runThrice(func() error {
// 每次重试前过闸门:暂停就在这里等着(不会把剩下的重试继续跑完),
// 停止就直接放弃 —— 否则点了暂停/停止还得等这个头像把三次重试耗完
if err := jobGate(); err != nil {
return noRetry(err)
}
return downloadAvatar(uid, url, path)
})
if err != nil {
// 停止不算故障,不用报「头像不可用」
if !errors.Is(err, errJobStopped) {
lPrintWarnf("uid %d 的头像不可用,该用户的弹幕只烧录文字不带头像:%v", uid, err)
}
return ""
}
return path
}
const (
// 头像下载的体积上限。真实头像最大也就几百 KB,这里给个大方的余量。
// 没有上限的话,CDN 一旦返回异常大的响应就能把内存吃光(实测单个头像吃掉 512MB)
avatarMaxBytes = 2 << 20
// 解码后允许的像素数上限(长 × 宽)。2048×2048 对头像来说是夸张地宽裕
avatarMaxPixels = 2048 * 2048
)
// 复用连接:一场直播要下几百个头像,每个都新建 client 就是几百次握手。
// 超时给 10 秒:头像只有几百 KB,超过就说明这个 CDN 节点不通,
// 早点超时能早点重试/过闸门,用户点暂停时也不用干等半分钟
var avatarClient = &http.Client{Timeout: 10 * time.Second}
func downloadAvatar(uid int64, url, path string) error {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "Mozilla/5.0")
resp, err := avatarClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
// 多读一个字节才能分辨「刚好到上限」和「超过了上限」
data, err := io.ReadAll(io.LimitReader(resp.Body, avatarMaxBytes+1))
if err != nil {
return err
}
if len(data) > avatarMaxBytes {
return noRetry(fmt.Errorf("头像体积超过 %d MB,不下载", avatarMaxBytes>>20))
}
src, err := decodeAvatar(data, resp.Header.Get("Content-Type"))
if err != nil {
return err
}
// 先写临时文件再改名:写一半失败留下的半截文件不能被当成可用缓存,
// 否则下次命中它就会把残图塞给 ffmpeg 当输入,整块渲染跟着失败
tmp := path + ".part"
out, err := os.Create(tmp)
if err != nil {
return err
}
if err := png.Encode(out, circleAvatar(src, avatarCacheSize)); err != nil {
out.Close()
_ = os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
_ = os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
}
// 解码头像图片。AcFun 的头像里混着 gif 和 webp:
// gif 由标准库解码,多帧的取第一帧;webp 只支持静态的,动画 webp 会报错,
// 这种情况下这个用户就不烧头像,只烧文字。
func decodeAvatar(data []byte, contentType string) (image.Image, error) {
src, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("图片解析不了(Content-Type: %s):%v", contentType, err)
}
// 防压缩炸弹:几十 KB 的 PNG/GIF 能解压出上亿像素,几 MB 的文件就能吃掉几 GB 内存。
// 头像最后只会被缩到几十像素(跟随字号),根本用不到这么大的原图
if b := src.Bounds(); b.Dx()*b.Dy() > avatarMaxPixels {
return nil, noRetry(fmt.Errorf("头像尺寸 %dx%d 超出上限(最多 %d 像素)", b.Dx(), b.Dy(), avatarMaxPixels))
}
return src, nil
}
// uid → 头像链接的记录,包含查不到的结果,避免同一个 uid 反复去问。
// 查到的结果还会落盘(见 avatarURLCacheFile),否则进程一重启就要把整场 uid 重查一遍。
var avatarURLMemo struct {
sync.Mutex
m map[int64]string
at map[int64]int64 // 记录写入时刻(Unix 秒),0 表示本进程刚查到的
dir string // 落盘文件所在目录,换目录会重新加载
loaded bool
}
// 反查结果的落盘文件(放在头像缓存目录里)与有效期。
// 有效期是为了让主播换头像后能自己更新过来:过期就重新查一次,查到了用新的
const (
avatarURLCacheFile = "uid_urls.jsonl"
avatarURLCacheTTL = 7 * 24 * time.Hour
)
type avatarURLCacheEntry struct {
UID int64 `json:"uid"`
URL string `json:"url"`
At int64 `json:"at"` // 写入时刻(Unix 秒)
}
// 实际去查头像的动作,单独拎出来方便测试替换掉
var lookupAvatarURL = fetchAvatarURL
// 读取落盘的反查记录。返回整理后的记录,以及文件里原有的行数(行数多于记录数
// 说明有重复或过期的行,调用方会顺手把文件压一遍)
func loadAvatarURLCache(dir string) (map[int64]avatarURLCacheEntry, int, error) {
path := filepath.Join(dir, avatarURLCacheFile)
f, err := os.Open(path)
if err != nil {
return nil, 0, err
}
defer f.Close()
entries := make(map[int64]avatarURLCacheEntry, 256)
total := 0
now := time.Now().Unix()
ttl := int64(avatarURLCacheTTL / time.Second)
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 64*1024), 64*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var e avatarURLCacheEntry
if json.Unmarshal([]byte(line), &e) != nil || e.UID <= 0 || e.URL == "" {
continue
}
total++
if now-e.At > ttl {
continue // 过期了:主播可能换过头像,重新查
}
entries[e.UID] = e // 后写的覆盖先写的
}
if err := scanner.Err(); err != nil {
return entries, total, err
}
return entries, total, nil
}
// 重写整个落盘文件(先写临时文件再改名,避免写一半断电留个坏文件)
func saveAvatarURLCache(dir string, entries map[int64]avatarURLCacheEntry) error {
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
path := filepath.Join(dir, avatarURLCacheFile)
tmp := path + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return err
}
w := bufio.NewWriter(f)
for _, e := range entries {
b, err := json.Marshal(e)
if err != nil {
continue
}
w.Write(b)
w.WriteByte('\n')
}
if err := w.Flush(); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(tmp, path)
}
// 追加一条反查结果(调用方持锁)。写不进去最多下次多查一遍,不影响合并
func appendAvatarURLCache(dir string, uid int64, url string) {
if url == "" {
return
}
if err := os.MkdirAll(dir, 0755); err != nil {
return
}
f, err := os.OpenFile(filepath.Join(dir, avatarURLCacheFile),
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return
}
defer f.Close()
b, err := json.Marshal(avatarURLCacheEntry{UID: uid, URL: url, At: time.Now().Unix()})
if err != nil {
return
}
b = append(b, '\n')
_, _ = f.Write(b)
}
// 把落盘记录读进内存(调用方持锁)。本进程刚查到的结果优先,不被文件里的旧值覆盖
func loadAvatarURLCacheInto(dir string) {
if avatarURLMemo.loaded && avatarURLMemo.dir == dir {
return
}
entries, total, err := loadAvatarURLCache(dir)
if err != nil && !os.IsNotExist(err) {
lPrintWarnf("读取头像链接记录失败,本次仍会按 uid 反查:%v", err)
}
if avatarURLMemo.m == nil {
avatarURLMemo.m = make(map[int64]string, len(entries))
}
if avatarURLMemo.at == nil {
avatarURLMemo.at = make(map[int64]int64, len(entries))
}
added := 0
for uid, e := range entries {
if _, ok := avatarURLMemo.m[uid]; ok {
continue
}
avatarURLMemo.m[uid] = e.URL
avatarURLMemo.at[uid] = e.At
added++
}
avatarURLMemo.dir = dir
avatarURLMemo.loaded = true
if total > len(entries) {
// 有重复或过期的行,顺手压一遍,免得文件一直长
_ = saveAvatarURLCache(dir, entries)
}
if added > 0 {
lPrintf("头像链接记录:从 %s 读到 %d 个用户的链接,这些不用再查", avatarURLCacheFile, added)
}
}
// 按 uid 反查头像链接。原版 .ass 里没有头像链接,只能这样补回来。
// 查到的结果会落盘,重启进程后不用把整场 uid 重新查一遍。
// 第二个返回值表示这次是不是真的发了请求(false = 用了内存或落盘里的记录)
func avatarURLByUID(uid int64, cacheDir string) (string, bool) {
if uid <= 0 {
return "", false
}
avatarURLMemo.Lock()
loadAvatarURLCacheInto(cacheDir)
if url, ok := avatarURLMemo.m[uid]; ok {
avatarURLMemo.Unlock()
return url, false
}
avatarURLMemo.Unlock()
url := lookupAvatarURL(uid)
avatarURLMemo.Lock()
if avatarURLMemo.m == nil {
avatarURLMemo.m = make(map[int64]string)
}
if avatarURLMemo.at == nil {
avatarURLMemo.at = make(map[int64]int64)
}
avatarURLMemo.m[uid] = url
avatarURLMemo.at[uid] = time.Now().Unix()
if url != "" {
appendAvatarURLCache(cacheDir, uid, url)
}
avatarURLMemo.Unlock()
return url, true
}
// 查头像链接用的 client,同样复用连接
var avatarAPIClient = &http.Client{Timeout: 15 * time.Second}
// 查 AcFun 的用户信息接口取头像地址
func fetchAvatarURL(uid int64) string {
addr := fmt.Sprintf("https://www.acfun.cn/rest/pc-direct/user/userInfo?userId=%d", uid)
req, err := http.NewRequest("GET", addr, nil)
if err != nil {
return ""
}
req.Header.Set("User-Agent", "Mozilla/5.0")
resp, err := avatarAPIClient.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
if err != nil {
return ""
}
var out struct {
Result int `json:"result"`
Profile struct {
HeadURL string `json:"headUrl"`
} `json:"profile"`
}
if json.Unmarshal(data, &out) != nil || out.Result != 0 {
return ""
}
return strings.TrimSpace(out.Profile.HeadURL)
}
// QSV 编码器的可用性探测结果,按 ffmpeg 路径缓存,避免分块时每块都试一次
var qsvCache struct {
sync.Mutex
path string
avail map[string]bool
}
// 探测某个 Intel QSV 编码器(hevc_qsv / h264_qsv)能不能跑,做一次真实的极小编码
func qsvEncoderAvailable(ffmpegFile, encoder string) bool {
qsvCache.Lock()
if qsvCache.avail != nil && qsvCache.path == ffmpegFile {
if ok, tested := qsvCache.avail[encoder]; tested {
qsvCache.Unlock()
return ok
}
}
qsvCache.Unlock()
cmd := exec.Command(ffmpegFile, "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "color=c=black:s=320x240:d=0.5:r=30",
"-c:v", encoder, "-frames:v", "5", "-f", "null", "-")
ok := cmd.Run() == nil
qsvCache.Lock()
if qsvCache.path != ffmpegFile || qsvCache.avail == nil {
qsvCache.path, qsvCache.avail = ffmpegFile, map[string]bool{}
}
qsvCache.avail[encoder] = ok
qsvCache.Unlock()
return ok
}
// 把这一场要用的头像预先缩到最终像素尺寸,写进 dir/chunkAvatarDir,
// 返回 uid → 滤镜里用的相对路径(形如 acfunlive_chunk_av/12.png)。
//
// 为什么要绕这一圈:每条弹幕的 overlay 都要一份头像输入,而滤镜图只能整段写在
// 命令行里(这一版 ffmpeg 没有 -filter_complex_script),Windows 又被 CreateProcess
// 限死 32767 字符。原来每个头像都是「C:\...\avatar_cache\12345678_abcdef123456.png」
// 这种 60 多字符的绝对路径,还各配一行 scale,单块只塞得下 120 条;换成短相对路径 +
// 预缩放之后能塞 200 条 —— 跨块续画(见 splitDanmuChunks)就靠这个空间。
func prepareAvatarInputs(dir string, avatars map[int64]string, size int) (map[int64]string, func(), error) {
noop := func() {}
full := filepath.Join(dir, chunkAvatarDir)
// 先清干净:上次跑崩留下的头像尺寸可能和这次不一样,留着会被当成可用的
_ = os.RemoveAll(full)
if err := os.MkdirAll(full, 0755); err != nil {
return nil, noop, fmt.Errorf("创建头像临时文件夹 %s 失败:%v", full, err)
}
uids := make([]int64, 0, len(avatars))
for uid := range avatars {
uids = append(uids, uid)
}
sort.Slice(uids, func(i, j int) bool { return uids[i] < uids[j] })
out := make(map[int64]string, len(avatars))
for i, uid := range uids {
name := strconv.Itoa(i+1) + ".png"
if err := scaleAvatarFile(avatars[uid], filepath.Join(full, name), size); err != nil {
lPrintWarnf("uid %d 的头像缩放失败,这个用户的弹幕只烧文字:%v", uid, err)
continue
}
// 用正斜杠:路径要写进滤镜图,反斜杠在 filter 参数里容易出歧义
out[uid] = filepath.ToSlash(filepath.Join(chunkAvatarDir, name))
}
return out, func() { _ = os.RemoveAll(full) }, nil
}
// 把缓存里那张圆形头像缩到 size×size 再写出去。缩放放在 Go 这边做,
// 滤镜里就不用每人再挂一行 scale 了
func scaleAvatarFile(src, dst string, size int) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
img, _, err := image.Decode(in)
if err != nil {
return err
}
scaled := image.NewRGBA(image.Rect(0, 0, size, size))
xdraw.CatmullRom.Scale(scaled, scaled.Bounds(), img, img.Bounds(), xdraw.Over, nil)
out, err := os.Create(dst)
if err != nil {
return err
}
if err := png.Encode(out, scaled); err != nil {
out.Close()
return err
}
return out.Close()
}
// 把头像居中裁方、缩放并圆形裁切,输出带透明通道的 PNG
func circleAvatar(src image.Image, size int) image.Image {
sb := src.Bounds()
side := sb.Dx()
if sb.Dy() < side {
side = sb.Dy()
}
cx, cy := sb.Min.X+sb.Dx()/2, sb.Min.Y+sb.Dy()/2
half := side / 2
sr := image.Rect(cx-half, cy-half, cx+half, cy+half)
scaled := image.NewRGBA(image.Rect(0, 0, size, size))
xdraw.CatmullRom.Scale(scaled, scaled.Bounds(), src, sr, xdraw.Over, nil)
// 圆形遮罩,边缘 2px 抗锯齿过渡
mask := image.NewAlpha(image.Rect(0, 0, size, size))
radius := float64(size) / 2
edge := 2.0
start := 1 - edge/radius
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
dx := float64(x-size/2) + 0.5
dy := float64(y-size/2) + 0.5
d := math.Sqrt(dx*dx+dy*dy) / radius
switch {
case d >= 1:
mask.SetAlpha(x, y, color.Alpha{A: 0})
case d > start:
a := uint8(255 * (1 - (d-start)/(edge/radius)))
mask.SetAlpha(x, y, color.Alpha{A: a})
default:
mask.SetAlpha(x, y, color.Alpha{A: 255})
}
}
}
dst := image.NewRGBA(image.Rect(0, 0, size, size))
draw.DrawMask(dst, dst.Bounds(), scaled, image.Point{}, mask, image.Point{}, draw.Over)
return dst
}