This commit is contained in:
tamakyi
2026-09-25 13:16:54 +08:00
commit 36fe7a978c
33 changed files with 9214 additions and 0 deletions
+836
View File
@@ -0,0 +1,836 @@
// 弹幕合成相关:读取 jsonl,下载头像并圆形裁切,调用 ffmpeg 合成带头像的弹幕层
package main
import (
"bufio"
"bytes"
"crypto/sha1"
"encoding/json"
"errors"
"fmt"
"image"
"image/color"
"image/draw"
_ "image/jpeg"
"image/png"
"io"
"math"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
xdraw "golang.org/x/image/draw"
)
// 头像缓存目录名
const avatarCacheDir = "avatar_cache"
// 命令行 -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
// 分块渲染产生的临时文件前缀
const chunkTempPrefix = "acfunlive_chunk_"
// 一个待渲染的弹幕块
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
}
// 弹幕合成入口(命令行)
// 弹幕文件可以给 .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)
}
if err := burnDanmu(src.jsonlPath, videoFile, outFile, offset); err != nil {
if err == errNoDanmu {
lPrintWarn(err.Error())
os.Exit(0)
}
lPrintErr(err)
os.Exit(1)
}
}
// 合成一个 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
}
}
}
// 下载并圆形裁切头像
avatarPaths := make(map[int64]string)
dir := filepath.Dir(jsonlFile)
cacheDir := filepath.Join(dir, 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
for i, uid := range uids {
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))
}
}
if url == "" {
continue
}
if path := ensureAvatar(uid, url, cacheDir); path != "" {
avatarPaths[uid] = path
}
}
if looked > 0 {
lPrintf("有 %d 个用户没有自带头像链接(多半是从 .ass 还原的弹幕),已按 uid 向 AcFun 反查", looked)
}
if len(avatarPaths) == 0 {
lPrintWarn("没有任何头像可用,弹幕将只烧录文字,请检查网络后重试合并")
}
ctx := &burnContext{
ffmpegFile: ffmpegFile,
videoFile: videoFile,
outFile: outFile,
offset: offset,
meta: meta,
avatars: avatarPaths,
bitrate: config.MergeBitrate + burnBitrate,
dir: dir,
}
chunks := splitDanmuChunks(records, offset, videoDuration)
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)
}
// 用 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)
return c.renderChunk(chunk, assName, c.outFile, false)
}
// 分块渲染:每块单独渲染成一个小片,最后用 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)
for i, ch := range 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))
lPrintf("正在渲染第 %d/%d 块(%s ~ %s,%d 条弹幕)",
i+1, len(chunks), formatSeconds(ch.start), formatSeconds(ch.end), len(ch.records))
if err := c.renderChunk(ch, assName, part, true); err != nil {
return fmt.Errorf("渲染第 %d/%d 块失败:%v", i+1, len(chunks), err)
}
parts = append(parts, part)
}
lPrintf("正在把 %d 个分块拼接成完整成片", len(parts))
if err := concatChunks(c.ffmpegFile, parts, c.outFile, c.dir); 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 && records[j].Time+offset < end {
j++
}
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 时表示这是分块渲染,需要按时长切出视频片段并重编码音频
func (c *burnContext) renderChunk(chunk danmuChunk, assBase, out string, cut bool) 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 := runFFmpeg(c.ffmpegFile, full, c.dir); 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 := runFFmpeg(c.ffmpegFile, full, c.dir); err != nil {
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
}
// 按目标码率构造编码器候选列表,QSV 可用时优先
func buildEncoders(bitrate int, ffmpegFile string) []videoEncoder {
var qOpts, xOpts []string
if bitrate > 0 {
br := strconv.Itoa(bitrate) + "k"
qOpts = []string{"-b:v", br}
xOpts = []string{"-b:v", br, "-preset", "medium"}
} else {
qOpts = []string{"-global_quality", "22", "-preset", "medium"}
xOpts = []string{"-crf", "18", "-preset", "medium"}
}
encoders := []videoEncoder{{"libx264", xOpts}}
if qsvAvailable(ffmpegFile) {
encoders = append([]videoEncoder{{"h264_qsv", qOpts}}, encoders...)
}
return encoders
}
// 运行 ffmpeg,输出直接透传到控制台
func runFFmpeg(ffmpegFile string, args []string, dir string) error {
cmd := exec.Command(ffmpegFile, args...)
cmd.Dir = dir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// 用 concat demuxer 把各分块的成片拼接起来
func concatChunks(ffmpegFile string, files []string, out, dir string) 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 := runFFmpeg(ffmpegFile, []string{"-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c", "copy", out}, dir); err != nil {
return fmt.Errorf("拼接各分块失败:%v", err)
}
return nil
}
// 把秒格式化成 ffmpeg 参数,保留毫秒
func formatSeconds(sec float64) string {
return strconv.FormatFloat(sec, 'f', 3, 64)
}
// 下载头像并圆形裁切,失败自动重试,返回缓存的 PNG 路径,失败返回空字符串
func ensureAvatar(uid int64, url, cacheDir string) string {
// 文件名里带上头像链接的摘要,主播换头像后不会一直用着旧缓存
sum := sha1.Sum([]byte(url))
path := filepath.Join(cacheDir, fmt.Sprintf("%d_%x.png", uid, sum[:6]))
if _, err := os.Stat(path); err == nil {
return path
}
if err := os.MkdirAll(cacheDir, 0755); err != nil {
lPrintWarnf("创建头像缓存文件夹 %s 失败,弹幕只烧录文字不带头像:%v", cacheDir, err)
return ""
}
// 清掉这个用户已经失效的旧头像
if olds, err := filepath.Glob(filepath.Join(cacheDir, strconv.FormatInt(uid, 10)+"_*.png")); err == nil {
for _, p := range olds {
if p != path {
_ = os.Remove(p)
}
}
}
err := runThrice(func() error {
return downloadAvatar(uid, url, path)
})
if err != nil {
lPrintWarnf("uid %d 的头像不可用,该用户的弹幕只烧录文字不带头像:%v", uid, err)
return ""
}
return path
}
func downloadAvatar(uid int64, url, path string) error {
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "Mozilla/5.0")
resp, err := client.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(resp.Body)
if err != nil {
return err
}
src, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return fmt.Errorf("解析失败(可能是 webp 格式):%v", err)
}
out, err := os.Create(path)
if err != nil {
return err
}
defer out.Close()
return png.Encode(out, circleAvatar(src, avatarCacheSize))
}
// 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
}
// 查 AcFun 的用户信息接口取头像地址
func fetchAvatarURL(uid int64) string {
client := &http.Client{Timeout: 15 * time.Second}
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 := client.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
ok bool
done bool
}
// 探测 Intel QSV(h264_qsv)硬件编码是否可用
func qsvAvailable(ffmpegFile string) bool {
qsvCache.Lock()
if qsvCache.done && qsvCache.path == ffmpegFile {
ok := qsvCache.ok
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", "h264_qsv", "-frames:v", "5", "-f", "null", "-")
ok := cmd.Run() == nil
qsvCache.Lock()
qsvCache.path, qsvCache.ok, qsvCache.done = ffmpegFile, ok, true
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
}