Files
acfunlive-remake/danmuburn.go
T
tamakyi e35a4f7cef 合并先在本地做完再落到成片目录;头像支持 gif 和静态 webp
起因是核查合并落盘和头像处理时发现的两处问题:

1) 成片落盘:ffmpeg 原来是直接写目标目录(automerge.go 里那句注释「不再事后再
   移动一次」)。成片目录可能是网络盘或外接盘,写到一半失败会在那里留下一个
   看起来正常的半成品。现在改成先本地合成、完成后再落盘:
   - 输出先写到弹幕文件所在目录(本地)的 acfunlive_chunk_merging_*
   - 写完后由 placeOutput 落盘:同盘直接 os.Rename(瞬时),跨盘退回拷贝;
     拷贝中途失败留下的是 *.acfunlive.part,一眼看得出不是成品
   - 成功或失败都会清掉本地临时文件,失败时两个目录都不留东西
   - 临时文件沿用 chunkTempPrefix,不再出现在文件管理的列表里(listFiles 过滤)
   - 启动时清理上次残留;删不掉时提示一句 —— 强杀程序不会连带杀掉 ffmpeg
     子进程,它可能还在写这个文件,这种情况下次启动会再清一次

2) 头像:downloadAvatar 只注册了 jpeg 和 png 解码器,AcFun 上的 gif 头像必然
   解码失败,静态 webp 同样。加上 _ "image/gif"(多帧取第一帧)和
   _ "golang.org/x/image/webp"(x/image 本来就是直接依赖,零新增模块)。
   动画 webp 两条路都解不开(x/image 报 invalid format,ffmpeg 5.0 也不行),
   保持只烧文字。解码失败时错误信息带上 Content-Type,方便排查是哪张图。

在隔离实例上用真 ffmpeg 跑了真合并验证:gif 头像和 webp 头像都进了缓存并烧进
画面(抽帧确认圆形头像);日志里 ffmpeg 的输出是 record 下的临时文件,完成后
才出现在 merged;坏视频合成失败后成片目录没有半成品、录播目录零残留;合并
中途强杀留下的临时文件在下次启动时被清掉。

新增 output_test.go(7 个)和 avatar_test.go(3 个);go test ./...、gofmt、
go vet 干净。
2026-09-25 17:34:31 +08:00

850 lines
25 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"
"strconv"
"strings"
"sync"
"time"
xdraw "golang.org/x/image/draw"
_ "golang.org/x/image/webp"
)
// 头像缓存目录名
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 := 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)
}
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
}
// 查 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
}