修分块渲染的弹幕轨迹:跨块续画 + 文字头像同源,并补失败推送与输出帧率设置

1. 密集弹幕时头像偏离轨迹、弹幕突然加速、之后一段时间屏幕空白

   分块渲染的固有问题,三个毛病叠在一起:

   - writeChunkAss 把 \move 的起止时间截断到块边界(End=min(t0+10, chunk.end)),
     但 x2 写死成 -Width —— 等于把 10 秒的路挤进可见的那零点几秒里,文字直接飞过去。
     而头像 overlay 的 x 一直是「固定 /10 秒 + 真实 t0」,于是同一时刻文字已经走到
     左边、头像还在半路,看起来就是「头像掉队(移动变慢)」。
   - 块尾还会被单块上限收紧成「下一条弹幕出现的时间」,最极端时窗口只剩 0.01 秒。
   - 每块只画「本块新出现」的弹幕,跨块的续画没有:块头屏幕上只剩刚出现的几条,
     要 10 秒才重新填满 —— 就是「弹幕突然加速,然后一段时间屏幕上什么都没有」。

   改法:文字层改成按绝对时间算轨迹(danmuTextX / danmuAvatarX / avatarXExpr 同源),
   任何一块里的文字速度都等于真实速度;分块器改成「续画(起播时间在 start-10 ~ start)
   + 本块新出现的」,块尾取「下一条弹幕的起播时间」,这样每条弹幕只在唯一一块里算
   新出现,显示期间跨过的后面几块把它接着画,一帧都不缺。

   代价是每块要多画一倍多的弹幕,所以顺手把头像一个「绝对路径 + 每人一行 scale」改成
   「预缩放 + 短相对路径」(prepareAvatarInputs / scaleAvatarFile),每条从实测 241
   字符降到约 130,单块上限才能从 120 抬到 200(200 条 ≈ 26600,仍低于自检阈值 30000
   和 Windows 的 32767)。

2. 合并失败没有任何通知

   notify.go 新增 mergeFail 事件与 notifyMergeFailure(),接在 autoMerge() 和手动合并的
   失败分支上。正常结束、文件里没有弹幕、以及用户自己点的停止都不算失败,不推 ——
   最后一种推「合并失败」会误导。配置项 notifyMergeFail 默认开,且和 notifyEnabled 一样
   在 Unmarshal 之前预置 true,免得老设置文件升级后被静默关掉。

3. 低帧率录播里的弹幕一格一格跳

   新增设置项 mergeFps,默认 0 = 跟随源片(什么都不做)。只在目标高于源片帧率时才加
   fps 滤镜,而且必须加在字幕/头像之前 —— 加在之后只是把已经画好的几帧复制一遍,
   看起来还是卡。上限 240。

4. 顺带

   - -danmuburn 命令行模式不走 initialize(),exeDir 从来没被赋值:头像缓存会落到
     「当前工作目录/avatar_cache」,旧版还会把这个相对路径写进滤镜,而 ffmpeg 的工作
     目录是弹幕目录,报「打不开 avatar_cache\xxx.png」整块失败。
   - cleanTempFiles 见到目录就 continue,预缩放头像那个临时文件夹永远清不掉。
   - command_len_test 不再用会过期的「每条 241 字符」估算,改成真跑一遍最坏情况;
     探针的头像路径也改成真实的短相对路径(原来量的是早已不存在的长绝对路径)。
   - 新增 TestWebuiSettingFieldsMatchInputs:把页面里 JS 的 settingFields/numberFields
     抠出来,跟所有 input/select/textarea 的 id 比对,漏登记的设置项会被抓住。

验证

- gofmt / go vet / go test(174 个)/ go test -race / staticcheck / GOOS=linux go build 全过。
- 反向验证 32 项全过:把每个修复临时退回,对应测试确实失败。
- 真实片段实测(2023AcFun 晚会 120 秒高峰段,689 条弹幕,10 秒窗口内最多同时 140 条):
  旧 6 块 / 绘制 689 次 / 54.1% 的弹幕文字被加速(中位 2.12x,最差 500x)/
  54.2% 有轨迹缺口(中位缺 5.29 秒)/ 78.3s;
  新 5 块 / 绘制 984 次 / 加速 0% / 缺口 0% / 98.8s(+26%)。
  对照帧里旧版在块尾前 0.1 秒屏幕已经全空,块头后 0.4 秒仍然全空;新版一直是满屏。
- 输出帧率:15fps 源配 mergeFps=60,成片 60fps,1 秒内 60 帧哈希互不相同(不是复制帧);
  默认设置下成片仍是 15fps。
This commit is contained in:
tamakyi
2026-09-26 16:34:47 +08:00
parent 3eb4aa3102
commit 6f3c8096b4
16 changed files with 1041 additions and 124 deletions
+290 -77
View File
@@ -76,24 +76,36 @@ const danmuShowSeconds = 10.0
// 弹幕一多整条命令就会超限且报错完全看不出原因,所以超过这个数量就分块渲染再拼接。
const maxDanmuPerChunk = 80
// 单块条数的硬上限。块尾会把「显示时间跨过块尾」的弹幕一并并入,
// 弹幕密集时(10 秒停留窗口里塞进几百条)单块就会膨胀到几百条 ——
// 一条弹幕在 -filter_complex 里约占 110 字节,几百条足以顶破上面那条命令行上限,
// 表现同样是「合并失败且看不出原因」,所以并入也要有个头。
// 到上限就把块尾收到下一条弹幕出现之前,让剩下的弹幕落到下一块。
// 单块绘制条数的硬上限(跨块续画 + 本块新出现的条数之和)。
//
// 上限值按最坏情况实测定的:一块里每条弹幕都来自不同用户时,每条要占
// 「-i 头像路径 + scale 行 + overlay 行」约 241 字符(2026-09-26 在真机量的),
// 120 条 ≈ 28864 字符,给中文路径和其它参数留出 3KB 余量;原来写 200 条时
// 实测 48288 字符,已经远超 Windows 的 32767,线上就是这里崩的。
const maxDanmuPerChunkHard = 120
// 每条弹幕在 -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)。
// 留一点余量,超了提早报错。filter 内容已经走 -filter_complex_script 文件,
// 这里管的是剩下的参数 —— 每个头像一个 -i 路径是大头
// 留一点余量,超了提早报错。这一版 ffmpeg(n9.0.2)**没有** -filter_complex_script,
// 滤镜图只能整段放在命令行里,所以滤镜里的路径长度直接决定单块能画多少条弹幕
const maxCommandLineLen = 30000
// 估算交给 exec 的命令行总长:每个参数按「一个空格 + 内容」算
@@ -113,7 +125,8 @@ const estimateAudioKbps = 192
type danmuChunk struct {
start float64 // 在视频时间轴上的起点(秒)
end float64 // 终点(秒),math.Inf(1) 表示一直到视频结尾
records []danmuRecord // 属于这一块的弹幕
carried int // records 里前多少条是上一块尾还在屏上的续画
records []danmuRecord // 这一块要画的弹幕:续画 + 本块新出现的
}
// 渲染分块时共用的参数
@@ -123,10 +136,44 @@ type burnContext struct {
outFile string
offset float64
meta danmuMeta
avatars map[int64]string
avatars map[int64]string // uid → 预缩放头像的相对路径(见 prepareAvatarInputs)
bitrate int
dir string
encoder string // 已经验证可用的编码器,分块时避免每块都重新回退
outFps int // 加到滤镜里的输出帧率,0 = 跟随源片、不加 fps 滤镜
layerFps int // 透明头像层模式的帧率(那种模式没有源片可跟)
}
// 透明头像层(无源片模式)默认的帧率
const defaultLayerFps = 30
// 设置里的输出帧率 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 倍)
@@ -145,6 +192,23 @@ 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 {
@@ -320,12 +384,13 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
}
// 弹幕文件的坐标系和视频对不上时,统一到视频坐标系(见 unifyDanmuSpace);
// 顺便拿到源片码率,用于「合成码率填 0」时的自动码率
// 顺便拿到源片码率(用于「合成码率填 0」时的自动码率)和帧率(用于输出帧率)
sourceKbps := 0
sourceFps := 0.0
if burnMode {
var vw, vh int
vw, vh, sourceKbps = probeVideoStream(ffmpegFile, videoFile)
unifyDanmuSpace(&meta, vw, vh)
info := probeVideoStream(ffmpegFile, videoFile)
sourceKbps, sourceFps = info.kbps, info.fps
unifyDanmuSpace(&meta, info.width, info.height)
lPrintf("视频编码器优先顺序:%s", strings.Join(availableEncoders(ffmpegFile), " → "))
}
// 字号在坐标系统一之后再定,此时 meta.PlayRes 就是成片画布
@@ -371,15 +436,36 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
}
}
// 输出帧率:设置填 0 = 跟随源片(不动)。低帧率录播(15/24fps)里弹幕是一格一格跳的,
// 提到更高帧率之后弹幕层按新帧率重绘,字和头像都顺了;源片本来就够高就不动
outFps := outputFps(config.MergeFps, sourceFps)
if burnMode && config.MergeFps > 0 && outFps == 0 {
lPrintf("输出帧率:源片 %.2f fps 已经不低于设置的 %d fps,不做处理", sourceFps, config.MergeFps)
}
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: avatarPaths,
avatars: avatarInputs,
bitrate: bitrate,
dir: filepath.Dir(jsonlFile),
dir: workDir,
outFps: outFps,
layerFps: layerFps(config.MergeFps),
}
chunks := splitDanmuChunks(records, offset, videoDuration)
@@ -403,8 +489,8 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
return ctx.burnSingle(records)
}
lPrintf("共 %d 条弹幕,%d 个用户的头像可用;超过单次渲染上限(%d 条),分 %d 块渲染后拼接",
len(records), len(avatarPaths), maxDanmuPerChunk, len(chunks))
lPrintf("共 %d 条弹幕,%d 个用户的头像可用;超过单块绘制上限(%d 条),分 %d 块渲染后拼接",
len(records), len(avatarPaths), maxDanmuPerChunkHard, len(chunks))
return ctx.burnChunked(chunks)
}
@@ -580,20 +666,28 @@ func unifyDanmuSpace(meta *danmuMeta, vw, vh int) bool {
return true
}
// 探测视频流的分辨率和码率(Kbps),拿不到的项返回 0
func probeVideoStream(ffmpegFile, path string) (int, int, int) {
// 视频流的关键参数。拿不到的项一律为 0
type videoStreamInfo struct {
width int
height int
kbps int // 视频码率(Kbps),流里没写时退回容器总码率
fps float64 // 帧率
}
// 探测视频流的分辨率、码率和帧率
func probeVideoStream(ffmpegFile, path string) videoStreamInfo {
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
var info videoStreamInfo
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])
info.kbps, _ = strconv.Atoi(m[1])
}
continue
}
@@ -602,14 +696,17 @@ func probeVideoStream(ffmpegFile, path string) (int, int, int) {
continue
}
if m := videoSizeRe.FindStringSubmatch(line); len(m) == 3 {
width, _ = strconv.Atoi(m[1])
height, _ = strconv.Atoi(m[2])
info.width, _ = strconv.Atoi(m[1])
info.height, _ = strconv.Atoi(m[2])
}
if m := videoBitrateRe.FindStringSubmatch(line); len(m) == 2 {
kbps, _ = strconv.Atoi(m[1])
info.kbps, _ = strconv.Atoi(m[1])
}
if m := videoFpsRe.FindStringSubmatch(line); len(m) == 2 {
info.fps, _ = strconv.ParseFloat(m[1], 64)
}
}
return width, height, kbps
return info
}
var (
@@ -618,6 +715,8 @@ var (
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`)
// 帧率,形如「1920x1080, 30 fps, 30 tbr, 1k tbn」
videoFpsRe = regexp.MustCompile(`, (\d+(?:\.\d+)?) fps`)
)
// 合成码率填 0(默认)时的算法:源片码率 + 弹幕余量。
@@ -717,8 +816,8 @@ func (c *burnContext) burnChunked(chunks []danmuChunk) error {
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))
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) {
// 块内时间加上块起点,折算成整片时间轴上的进度
@@ -746,7 +845,21 @@ func (c *burnContext) burnChunked(chunks []danmuChunk) error {
return nil
}
// 把弹幕按时间切成若干块,块的时间范围首尾相接,保证视频不会缺帧
// 把弹幕按时间切成若干块,块的时间范围首尾相接,保证视频不会缺帧。
//
// 每块画两组东西:
// 1. 「续画」:上一块尾还在屏上的弹幕。一条弹幕停留 10 秒,块边界经常从它中间切过去。
// 不续画的话,每个块头屏幕上就只剩刚出现的那几条,要 10 秒才重新填满 ——
// 看起来就是「弹幕突然加速,然后一段时间屏幕上没有弹幕」;
// 2. 起播时间落在本块里的弹幕。
//
// 两组合起来不能超过 maxDanmuPerChunkHard(命令行长度限制),所以块的长度是
// 「预算 ÷ 弹幕密度」自己算出来的。续画把预算吃光时挤掉最早的那几条
// (它们已经滚到左边缘快出画了),但每块一定留 minNewDanmuPerChunk 条新弹幕 ——
// 否则块长会退化成 0,无限细分下去。
//
// 块尾统一取「下一条弹幕的起播时间」:这样每条弹幕只会在唯一一块里算「新出现」,
// 而它显示期间跨过的后面几块会把它当续画接着画,一帧都不缺。
// maxEnd 大于 0 时表示视频总时长,块尾不会超过它
func splitDanmuChunks(records []danmuRecord, offset, maxEnd float64) []danmuChunk {
n := len(records)
@@ -757,42 +870,61 @@ func splitDanmuChunks(records []danmuRecord, offset, maxEnd float64) []danmuChun
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)
i := 0
// records[:next] 都已经在更早的块里「新出现」过了
next := 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
for next < n {
// 续画:起播时间在 (start-10, start) 的弹幕,此刻都还在屏上
from := next
for from > 0 && appear(from-1) > start-danmuShowSeconds {
from--
}
// 块尾取第 limit 条弹幕显示结束的时刻
end := records[limit-1].Time + offset + danmuShowSeconds
if maxEnd > 0 && end > maxEnd {
end = maxEnd
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
}
if end <= start {
end = start + danmuShowSeconds
hi := next + room
if hi > n {
hi = n
}
// 起点早于块尾的弹幕(显示会跨过块尾)也并入这一块,但不许超过硬上限
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
end := math.Inf(1)
if hi < n {
end = appear(hi)
if maxEnd > 0 && end > maxEnd {
end = maxEnd
}
if end <= start {
// 同一时刻挤了一堆弹幕(时间戳重复)时块尾会和块头重合,那种块
// 渲染出来一帧都没有。往后挪一点,被跳过的弹幕下一块会当续画接着画
end = start + minChunkSpan
}
}
chunks = append(chunks, danmuChunk{start: start, end: end, records: records[i:j]})
i = j
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)
@@ -813,14 +945,29 @@ func writeChunkAss(path string, meta danmuMeta, chunk danmuChunk, offset float64
}
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,
meta.PlayResX+avW+avGap, y, -r.Width, y, text)
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 退出
@@ -867,8 +1014,8 @@ func (c *burnContext) renderChunk(chunk danmuChunk, assBase, out string, cut boo
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))
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])
@@ -876,24 +1023,31 @@ func (c *burnContext) renderChunk(chunk danmuChunk, assBase, out string, cut boo
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"
}
// 把每个头像缩放到弹幕字号对应的直径,同一个头像被多条弹幕使用时用 split 复制
// 头像已经预缩放成 ad×ad(见 prepareAvatarInputs),这里直接用输入 pad;
// 只有同一个人的头像在这一块里要用到多条弹幕时才 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())
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 {
@@ -907,21 +1061,21 @@ func (c *burnContext) renderChunk(chunk danmuChunk, assBase, out string, cut boo
if !visible {
continue
}
avLabel := strconv.Itoa(idx)
avPad := strconv.Itoa(idx) + ":v"
if uidCount[r.UID] > 1 {
avLabel = strconv.Itoa(idx) + "_" + strconv.Itoa(uidCopy[r.UID])
avPad = "av" + strconv.Itoa(idx) + "_" + strconv.Itoa(uidCopy[r.UID])
uidCopy[r.UID]++
}
t0 := r.Time + c.offset
// 头像在文字左侧并留出间隔:ASS 里的文字左边缘是这里加 ad+gap,头像跟着文字一起滚动。
// 位置公式 x = 画面宽 - (画面宽+总宽)*(t-t0)/10,这里 t 是块内时间,把 t0 也换算到块内坐标
// 公式与文字层的 danmuTextX 同源(见 avatarXExpr):这里 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)
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][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)
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 {
@@ -1736,6 +1890,65 @@ func qsvEncoderAvailable(ffmpegFile, encoder string) bool {
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()