Files
acfunlive-remake/danmuburn.go
T
tamakyi a24425e16d 修第三批 P2:除零、停止/暂停的响应与状态一致性、临时文件、带 BOM 的 ASS
一、弹幕滚动时间不再整数除零
   write() 里拿 (PlayResX+length) 做除数,PlayResX 被配成 0 又收到一条空内容弹幕时
   分母正好是 0,直接 panic 终止整场录制。抽成 scrollMarks 并给分母为 0 的兜底
   (退回半个停留时长,至少让它正常显示出来)。

二、点停止不再要等整个块渲完
   jobAttach 原来只看 paused:停止请求若落在 jobGate 之后、ffmpeg 启动过程中,
   job.proc 还没登记,jobStop 拿不到进程可杀,用户得等整个块渲完(大块要几分钟)。
   现在 attach 时发现任务已请求停止就地把它杀掉,上层拿到 errJobStopped,
   卡片显示的是「已停止」而不是「失败」。

三、暂停没挂住就回滚状态
   jobPause 先把 job.paused 置 true 再调 suspendProcess,挂起失败时状态已经改了:
   界面显示「已暂停」而 ffmpeg 还在跑。失败时回滚(只在 proc 仍是同一个进程时回滚)。
   jobResume 同理:恢复不成放回「已暂停」,免得显示在跑却停着。

四、.ass 还原的临时 jsonl 用完就删
   还原结果写不进录播目录时会退到系统临时目录的固定名文件,原来从不清理,
   每失败一次留一份。新增 danmuSource.tempJsonl 标记 + dropTempDanmu 收尾删除;
   只删标记过的临时文件 —— 正常情况下那个路径是用户录播目录里的同名 jsonl,
   删了就是丢数据。

五、带 BOM 的 ASS 能整份解析
   带 UTF-8 BOM 时首行是 BOM+[Script Info],一个节都进不去,最后只报
   「可能不是弹幕文件」,跟真实原因完全对不上。解析时剥掉 BOM。

六、头像下载期间暂停/停止不用再干等
   单次超时 30 秒 × 3 次重试,最坏 96 秒才响应暂停/停止。单次超时收到 10 秒
   (头像只有几百 KB),并且每次重试前过闸门:暂停在这里等、停止直接放弃,
   不再把剩下的重试跑完;停止也不再打「头像不可用」的告警。

验证:
- gofmt -l 空;go vet ./... 空;go build 与 GOOS=linux go build 通过
- go test ./... 全绿(10.9s);go test -race ./... 全绿(14.5s)
- 新增 p2_fixes_test.go 8 个用例;本轮 6 项加上一批 6 项共 12 项反向验证全部通过
  (每项临时退回源码 → 对应测试确实失败 → 恢复)
- 51897 隔离实例上完整复测:e2e.py 37 项全过、smoke_extra.py 33 项全过
2026-09-26 10:07:30 +08:00

1331 lines
44 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"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
xdraw "golang.org/x/image/draw"
_ "golang.org/x/image/webp"
)
// 头像缓存目录名
const avatarCacheDir = "avatar_cache"
// 头像缓存放程序所在目录:换保存目录(甚至换盘)都还能复用,
// 不用为同一批观众重新下一次。旧版本放在弹幕文件同目录,读取时会搬过来。
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
// 单块条数的硬上限。块尾会把「显示时间跨过块尾」的弹幕一并并入,
// 弹幕密集时(10 秒停留窗口里塞进几百条)单块就会膨胀到几百条 ——
// 一条弹幕在 -filter_complex 里约占 110 字节,几百条足以顶破上面那条命令行上限,
// 表现同样是「合并失败且看不出原因」,所以并入也要有个头。
// 到上限就把块尾收到下一条弹幕出现之前,让剩下的弹幕落到下一块。
const maxDanmuPerChunkHard = 200
// 分块渲染产生的临时文件前缀
const chunkTempPrefix = "acfunlive_chunk_"
// 预估成片体积时给音频留的码率(Kbps):分块时音频固定重编码成 192k,
// 单块时是直接拷流,这里统一按 192 估,反正渲染过 5% 后会被实测外推值顶掉
const estimateAudioKbps = 192
// 一个待渲染的弹幕块
type danmuChunk struct {
start float64 // 在视频时间轴上的起点(秒)
end float64 // 终点(秒),math.Inf(1) 表示一直到视频结尾
records []danmuRecord // 属于这一块的弹幕
}
// 渲染分块时共用的参数
type burnContext struct {
ffmpegFile string
videoFile string // 为空表示输出透明头像层
outFile string
offset float64
meta danmuMeta
avatars map[int64]string
bitrate int
dir string
encoder string // 已经验证可用的编码器,分块时避免每块都重新回退
}
// 头像直径,与文字的实际墨迹高度一致(字号是 em 框,墨迹约为 0.8 倍)
func avatarSize(fontSize int) int {
return fontSize * 4 / 5
}
// 头像和弹幕文字之间的间隔
func avatarGap(fontSize int) int {
return fontSize / 6
}
// 画布短边:横屏取高、竖屏取宽。弹幕文字和头像的尺寸都以它为准 ——
// 竖屏画面窄,按高度算出来的字会大到一条弹幕就铺满整行。
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); err != nil {
dropTempDanmu(src)
if err == errNoDanmu {
lPrintWarn(err.Error())
os.Exit(0)
}
lPrintErr(err)
os.Exit(1)
}
dropTempDanmu(src)
}
// 合成一个 jsonl(和可选的视频)为带头像的弹幕层
// videoFile 为空时输出透明头像层(ProRes 4444),否则把弹幕层压进视频
func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) 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 路径")
}
// 弹幕可能录得比视频久(视频中途中断过),超出视频时长的部分没法渲染,直接丢掉
videoDuration := 0.0
if burnMode {
videoDuration = probeDuration(ffmpegFile, videoFile)
if videoDuration > 0 {
kept := records[:0]
for _, r := range records {
if r.Time+offset < videoDuration {
kept = append(kept, r)
}
}
if dropped := len(records) - len(kept); dropped > 0 {
lPrintWarnf("有 %d 条弹幕超出了录播视频的时长(%.1f 秒),已跳过", dropped, videoDuration)
}
records = kept
if len(records) == 0 {
return errNoDanmu
}
}
}
// 弹幕文件的坐标系和视频对不上时,统一到视频坐标系(见 unifyDanmuSpace);
// 顺便拿到源片码率,用于「合成码率填 0」时的自动码率
sourceKbps := 0
if burnMode {
var vw, vh int
vw, vh, sourceKbps = probeVideoStream(ffmpegFile, videoFile)
unifyDanmuSpace(&meta, vw, vh)
lPrintf("视频编码器优先顺序:%s", strings.Join(availableEncoders(ffmpegFile), " → "))
}
// 字号在坐标系统一之后再定,此时 meta.PlayRes 就是成片画布
applyFontSize(&meta, config.DanmuFontSize)
// 弹道按最终字号统一重排,避免还原弹幕或改字号后同弹道追尾
relayoutLanes(records, meta)
// 下载并圆形裁切头像
avatarPaths := make(map[int64]string)
// 缓存放在程序所在目录,换保存目录(甚至换盘)都不用重新下载;
// 旧版本放在弹幕文件同目录,作为回退来源读一次并搬过去
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
}
}
looked := 0
jobSetPhase(jobPhaseAvatar)
jobAvatars(0, len(uids))
for i, uid := range uids {
// 这一阶段既要下网络又要解图片,允许在这里暂停 / 停止
if err := jobGate(); err != nil {
return err
}
url := urlByUID[uid]
if url == "" {
// 从 .ass 还原出来的弹幕没有头像链接,按 uid 反查一次
url = avatarURLByUID(uid)
looked++
// 反查比较慢,定期报一下进度,避免看起来像卡住了
if looked%50 == 0 || i+1 == len(uids) {
lPrintf("正在获取头像:已处理 %d/%d 个用户", i+1, len(uids))
}
}
jobAvatars(i+1, len(uids))
if url == "" {
continue
}
if path := ensureAvatar(uid, url, cacheDir, legacyCache); path != "" {
avatarPaths[uid] = path
}
}
if looked > 0 {
lPrintf("有 %d 个用户没有自带头像链接(多半是从 .ass 还原的弹幕),已按 uid 向 AcFun 反查", looked)
}
if len(avatarPaths) == 0 {
lPrintWarn("没有任何头像可用,弹幕将只烧录文字,请检查网络后重试合并")
}
// 合成码率:填了值就用它;填 0(默认)按「源片码率 + 弹幕余量」算,探测不到才退回质量模式
bitrate := config.MergeBitrate + 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)")
}
}
ctx := &burnContext{
ffmpegFile: ffmpegFile,
videoFile: videoFile,
outFile: outFile,
offset: offset,
meta: meta,
avatars: avatarPaths,
bitrate: bitrate,
dir: filepath.Dir(jsonlFile),
}
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), maxDanmuPerChunk, len(chunks))
return ctx.burnChunked(chunks)
}
// 渲染前按最终字号重排弹道: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
}
// 探测视频流的分辨率和码率(Kbps),拿不到的项返回 0
func probeVideoStream(ffmpegFile, path string) (int, int, int) {
cmd := exec.Command(ffmpegFile, "-hide_banner", "-i", path)
var buf bytes.Buffer
cmd.Stderr = &buf
// 没指定输出文件时 ffmpeg 会返回非 0,但流信息已经打印出来了
_ = cmd.Run()
width, height, kbps := 0, 0, 0
for _, line := range strings.Split(buf.String(), "\n") {
// 容器的总码率(含音频)先记下来,视频流自己没写码率时用它
if strings.Contains(line, "Duration:") {
if m := containerBitrateRe.FindStringSubmatch(line); len(m) == 2 {
kbps, _ = strconv.Atoi(m[1])
}
continue
}
// 只看视频流,跳过封面图(mjpeg 之类的 attached pic)那几行
if !strings.Contains(line, "Video:") || strings.Contains(line, "attached pic") {
continue
}
if m := videoSizeRe.FindStringSubmatch(line); len(m) == 3 {
width, _ = strconv.Atoi(m[1])
height, _ = strconv.Atoi(m[2])
}
if m := videoBitrateRe.FindStringSubmatch(line); len(m) == 2 {
kbps, _ = strconv.Atoi(m[1])
}
}
return width, height, kbps
}
var (
// 逗号后紧跟的「数字x数字」就是分辨率,前面的编码器名里也有 0x… 这种十六进制,靠逗号区分
videoSizeRe = regexp.MustCompile(`, (\d{2,5})x(\d{2,5})[, \[]`)
videoBitrateRe = regexp.MustCompile(`, (\d+) kb/s`)
// 容器总码率,形如「Duration: 00:02:55.07, start: 0.000000, bitrate: 1767 kb/s」
containerBitrateRe = regexp.MustCompile(`bitrate: (\d+) kb/s`)
)
// 合成码率填 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
func probeDuration(ffmpegFile, path string) float64 {
cmd := exec.Command(ffmpegFile, "-hide_banner", "-i", path)
var buf bytes.Buffer
cmd.Stderr = &buf
// 没指定输出文件时 ffmpeg 会返回非 0,但时长信息已经打印出来了
_ = cmd.Run()
m := durationRe.FindStringSubmatch(buf.String())
if len(m) != 4 {
return 0
}
h, _ := strconv.Atoi(m[1])
mi, _ := strconv.Atoi(m[2])
s, _ := strconv.ParseFloat(m[3], 64)
return float64(h)*3600 + float64(mi)*60 + s
}
var durationRe = regexp.MustCompile(`Duration: (\d+):(\d+):(\d+\.\d+)`)
// 弹幕不多时直接一次渲染完,同样从 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 条弹幕)",
i+1, len(chunks), formatSeconds(ch.start), formatSeconds(ch.end), len(ch.records))
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
}
// 把弹幕按时间切成若干块,块的时间范围首尾相接,保证视频不会缺帧
// 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}}
}
chunks := make([]danmuChunk, 0, n/maxDanmuPerChunk+1)
i := 0
start := 0.0
for i < n {
limit := i + maxDanmuPerChunk
if limit >= n {
chunks = append(chunks, danmuChunk{start: start, end: math.Inf(1), records: records[i:]})
break
}
// 块尾取第 limit 条弹幕显示结束的时刻
end := records[limit-1].Time + offset + danmuShowSeconds
if maxEnd > 0 && end > maxEnd {
end = maxEnd
}
if end <= start {
end = start + danmuShowSeconds
}
// 起点早于块尾的弹幕(显示会跨过块尾)也并入这一块,但不许超过硬上限
j := limit
for j < n && j < i+maxDanmuPerChunkHard && records[j].Time+offset < end {
j++
}
// 被硬上限挡住时,把块尾收紧到下一条弹幕出现之前:它因此落在下一块里,
// 而且不会因为「整条都早于下一块块头」在下一块里被当成无效区间丢掉
if j < n && records[j].Time+offset < end {
if t := records[j].Time + offset; t > start {
end = t
}
}
chunks = append(chunks, danmuChunk{start: start, end: end, records: records[i:j]})
i = j
start = end
}
return chunks
}
// 为分块渲染生成只包含该块弹幕的字幕文件(相对块起点计时)
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 {
t0 := r.Time + offset
visStart := math.Max(t0, chunk.start)
visEnd := math.Min(t0+danmuShowSeconds, chunk.end)
if visEnd <= visStart {
continue
}
text := r.Name + "(" + strconv.FormatInt(r.UID, 10) + "):" + r.Content
y := meta.FontSize * r.Lane
fmt.Fprintf(f, dialogueNickTmpl,
assTime(int64((visStart-chunk.start)*1e9)), assTime(int64((visEnd-chunk.start)*1e9)),
strings.ReplaceAll(r.Name, ",", " "), r.UID,
meta.PlayResX+avW+avGap, y, -r.Width, y, text)
}
return nil
}
// 渲染一个块:把这一块的弹幕(文字层 + 头像层)压进对应的视频片段
// cut 为 true 时表示这是分块渲染,需要按时长切出视频片段并重编码音频
// cb 用来回传 ffmpeg 的实时进度(已输出时长 / 倍速 / 帧率 / 已写出字节),可为 nil
func (c *burnContext) renderChunk(chunk danmuChunk, assBase, out string, cut bool, cb ffmpegProgress) error {
burnMode := c.videoFile != ""
// 统计这一块用到的头像,按首次出现顺序分配输入序号(输入 0 是视频或背景)
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 _, 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=30,format=rgba",
c.meta.PlayResX, c.meta.PlayResY, c.chunkDuration(chunk)+0.5))
}
for _, uid := range uidOrder {
args = append(args, "-i", c.avatars[uid])
}
fc := &strings.Builder{}
prev := "0:v"
if burnMode {
fmt.Fprintf(fc, "[%s]subtitles=filename='%s'[b0];\n", prev, assBase)
prev = "b0"
}
// 把每个头像缩放到弹幕字号对应的直径,同一个头像被多条弹幕使用时用 split 复制
ad := avatarSize(c.meta.FontSize)
gap := avatarGap(c.meta.FontSize)
for _, uid := range uidOrder {
idx := uidInput[uid]
if uidCount[uid] == 1 {
fmt.Fprintf(fc, "[%d:v]scale=%d:%d[av%d];\n", idx, ad, ad, idx)
} else {
outs := &strings.Builder{}
for j := 0; j < uidCount[uid]; j++ {
fmt.Fprintf(outs, "[av%d_%d]", idx, j)
}
fmt.Fprintf(fc, "[%d:v]scale=%d:%d,split=%d%s;\n", idx, ad, ad, uidCount[uid], outs.String())
}
}
uidCopy := make(map[int64]int) // 每个头像当前用掉的份数
for n, r := range chunk.records {
idx, ok := uidInput[r.UID]
if !ok {
continue
}
avLabel := strconv.Itoa(idx)
if uidCount[r.UID] > 1 {
avLabel = strconv.Itoa(idx) + "_" + strconv.Itoa(uidCopy[r.UID])
uidCopy[r.UID]++
}
t0 := r.Time + c.offset
visStart := math.Max(t0, chunk.start)
visEnd := math.Min(t0+danmuShowSeconds, chunk.end)
if visEnd <= visStart {
continue
}
// 头像在文字左侧并留出间隔:ASS 里的文字左边缘是这里加 ad+gap,头像跟着文字一起滚动。
// 位置公式 x = 画面宽 - (画面宽+总宽)*(t-t0)/10,这里 t 是块内时间,把 t0 也换算到块内坐标
total := ad + gap + r.Width
shift := chunk.start - t0
x := fmt.Sprintf("%d-(%d+%d)*(t%+.3f)/10", c.meta.PlayResX, c.meta.PlayResX, total, shift)
y := c.meta.FontSize*r.Lane + (c.meta.FontSize-ad)/2 // 头像与文字墨迹垂直居中
outLabel := fmt.Sprintf("s%d", n)
fmt.Fprintf(fc, "[%s][av%s]overlay=x='%s':y=%d:enable='between(t,%.3f,%.3f)'[%s];\n",
prev, avLabel, 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))
}
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) {
lPrintWarnf("编码器 %s 运行失败,回退到 %s:%v", enc.name, encoders[i+1].name, err)
continue
}
return fmt.Errorf("ffmpeg 运行失败:%v", lastErr)
}
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
cmd.Stderr = os.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 cmd.Wait()
}
cmd.Stdout = os.Stdout
if err := cmd.Start(); err != nil {
return err
}
jobAttach(cmd)
defer jobDetach(cmd)
return cmd.Wait()
}
// 解析 -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
}
// 把秒格式化成 ffmpeg 参数,保留毫秒
func formatSeconds(sec float64) string {
return strconv.FormatFloat(sec, 'f', 3, 64)
}
// 下载头像并圆形裁切,失败自动重试,返回缓存的 PNG 路径,失败返回空字符串。
// legacyDirs 是旧版本放缓存的位置,命中就搬过来用,省得换保存目录后重新下一次。
func ensureAvatar(uid int64, url, cacheDir string, legacyDirs ...string) string {
// 文件名里带上头像链接的摘要,主播换头像后不会一直用着旧缓存
sum := sha1.Sum([]byte(url))
name := fmt.Sprintf("%d_%x.png", uid, sum[:6])
path := filepath.Join(cacheDir, name)
if _, err := os.Stat(path); err == nil {
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 {
return path
}
// 跨盘改名会失败,退回复制
if copyFile(old, path) == nil {
_ = os.Remove(old)
return path
}
}
// 清掉这个用户已经失效的旧头像
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
}
out, err := os.Create(path)
if err != nil {
return err
}
defer out.Close()
return png.Encode(out, circleAvatar(src, avatarCacheSize))
}
// 解码头像图片。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 反复去问
var avatarURLMemo struct {
sync.Mutex
m map[int64]string
}
// 实际去查头像的动作,单独拎出来方便测试替换掉
var lookupAvatarURL = fetchAvatarURL
// 按 uid 反查头像链接。原版 .ass 里没有头像链接,只能这样补回来
func avatarURLByUID(uid int64) string {
if uid <= 0 {
return ""
}
avatarURLMemo.Lock()
if avatarURLMemo.m == nil {
avatarURLMemo.m = make(map[int64]string)
}
if url, ok := avatarURLMemo.m[uid]; ok {
avatarURLMemo.Unlock()
return url
}
avatarURLMemo.Unlock()
url := lookupAvatarURL(uid)
avatarURLMemo.Lock()
avatarURLMemo.m[uid] = url
avatarURLMemo.Unlock()
return url
}
// 查头像链接用的 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
}
// 把头像居中裁方、缩放并圆形裁切,输出带透明通道的 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
}