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

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
+9 -1
View File
@@ -263,6 +263,8 @@ func autoMerge(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int
lPrintWarn("自动合并已被手动停止" + longID(uid)) lPrintWarn("自动合并已被手动停止" + longID(uid))
} }
jobFinish(jobErrText(err)) jobFinish(jobErrText(err))
// 失败要推一条,否则只有盯着控制台才知道这场没合成出来
notifyMergeFailure(uid, autoMergeOutFile(jsonlFile), err)
} }
// 把错误翻成给界面看的原因:正常结束和「没有弹幕」都不算失败 // 把错误翻成给界面看的原因:正常结束和「没有弹幕」都不算失败
@@ -276,6 +278,12 @@ func jobErrText(err error) string {
return err.Error() return err.Error()
} }
// 自动合并成片的最终路径:成片目录 + 与弹幕文件同名的「_弹幕合并.mp4」
func autoMergeOutFile(jsonlFile string) string {
name := filepath.Base(strings.TrimSuffix(jsonlFile, ".jsonl") + "_弹幕合并.mp4")
return filepath.Join(mergeSaveDir(), name)
}
// 执行自动合并及后续的删除/移动。日志都在内部按原措辞打印,返回值只给上层写进度用 // 执行自动合并及后续的删除/移动。日志都在内部按原措辞打印,返回值只给上层写进度用
func autoMergeJob(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int64) error { func autoMergeJob(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int64) error {
lPrintln("开始自动合并" + longID(uid) + "的录播和弹幕层") lPrintln("开始自动合并" + longID(uid) + "的录播和弹幕层")
@@ -303,7 +311,7 @@ func autoMergeJob(uid int, segs []recordSegment, jsonlFile string, danmuStartNs
lPrintWarnf("创建成片文件夹 %s 失败:%v", mergeDir, err) lPrintWarnf("创建成片文件夹 %s 失败:%v", mergeDir, err)
return fmt.Errorf("创建成片文件夹 %s 失败:%v", mergeDir, err) return fmt.Errorf("创建成片文件夹 %s 失败:%v", mergeDir, err)
} }
outFile := filepath.Join(mergeDir, filepath.Base(strings.TrimSuffix(jsonlFile, ".jsonl")+"_弹幕合并.mp4")) outFile := autoMergeOutFile(jsonlFile)
tmpOut := tempMergeOutput(jsonlFile, filepath.Base(outFile)) tmpOut := tempMergeOutput(jsonlFile, filepath.Base(outFile))
// 输入大小按各分段实际位置合计(录制结束时文件可能已被移动到保存目录) // 输入大小按各分段实际位置合计(录制结束时文件可能已被移动到保存目录)
+94 -15
View File
@@ -26,29 +26,76 @@ func denseRecords(n int) []danmuRecord {
return records return records
} }
// 单块条数必须被硬上限挡住,否则 -filter_complex 会撑爆命令行长度 // 单块条数必须被硬上限挡住(续画 + 本块新出现的条数一起算),否则 -filter_complex
// 会撑爆命令行长度。
//
// 「新出现」的那些条按顺序拼起来必须正好是原始列表:续画只是同一批弹幕在后面几块里
// 接着画,不能算成新弹幕,更不能有哪一条一次都没算成新出现(那就整条丢了)
func TestSplitDanmuChunksCapsBlockSize(t *testing.T) { func TestSplitDanmuChunksCapsBlockSize(t *testing.T) {
records := denseRecords(600) records := denseRecords(600)
chunks := splitDanmuChunks(records, 0, 0) chunks := splitDanmuChunks(records, 0, 0)
if len(chunks) < 2 { if len(chunks) < 2 {
t.Fatalf("这么多弹幕应当分成多块,实际 %d 块", len(chunks)) t.Fatalf("这么多弹幕应当分成多块,实际 %d 块", len(chunks))
} }
total := 0 fresh := make([]danmuRecord, 0, len(records))
for i, ch := range chunks { for i, ch := range chunks {
if n := len(ch.records); n > maxDanmuPerChunkHard { if n := len(ch.records); n > maxDanmuPerChunkHard {
t.Fatalf("第 %d 块有 %d 条弹幕,超过硬上限 %d", i+1, n, maxDanmuPerChunkHard) t.Fatalf("第 %d 块有 %d 条弹幕,超过硬上限 %d", i+1, n, maxDanmuPerChunkHard)
} }
total += len(ch.records) if ch.carried < 0 || ch.carried > len(ch.records) {
t.Fatalf("第 %d 块的 carried=%d 越界(这一块共 %d 条)", i+1, ch.carried, len(ch.records))
}
fresh = append(fresh, ch.records[ch.carried:]...)
}
if len(fresh) != len(records) {
t.Fatalf("新出现的弹幕总数应当是 %d,实际 %d", len(records), len(fresh))
}
for i := range records {
if fresh[i].Time != records[i].Time || fresh[i].Content != records[i].Content {
t.Fatalf("第 %d 条新弹幕对不上:拿到 %+v,期望 %+v", i, fresh[i], records[i])
} }
if total != len(records) {
t.Fatalf("分块把弹幕弄丢或弄重了:%d -> %d", len(records), total)
} }
} }
// 硬上限会把一部分弹幕挤到下一块,这些弹幕在下一块里仍要从自己的时间点开始显示: // 一条弹幕停留 10 秒,块边界经常从它中间切过去:分块器必须把它「续画」到后面几块里。
// 块尾不收紧的话,下一块的块头会落在它们后面,开头就被吃掉了 // 不续画的话每个块头屏幕上只剩刚出现的那几条,要 10 秒才重新填满 —— 观众看到的就是
func TestSplitDanmuChunksLosesNoDanmu(t *testing.T) { // 「弹幕突然加速冲过去,然后一段时间屏幕上什么都没有」
records := denseRecords(600) func TestSplitDanmuChunksCarriesOnScreenDanmu(t *testing.T) {
records := denseRecords(600) // 每 0.02 秒一条,10 秒窗口里同时有 500 条
chunks := splitDanmuChunks(records, 0, 0)
carriedTotal, fresh := 0, 0
for i, ch := range chunks {
carriedTotal += ch.carried
fresh += len(ch.records) - ch.carried
for _, r := range ch.records[:ch.carried] {
// 续画只能是「上几块里已经出现过、此刻还在屏上」的那些
if r.Time >= ch.start {
t.Fatalf("第 %d 块(起点 %.3f)把时间 %.3f 的弹幕当成了续画,它不是上一块留下来的",
i+1, ch.start, r.Time)
}
if r.Time < ch.start-danmuShowSeconds {
t.Fatalf("第 %d 块(起点 %.3f)续画了时间 %.3f 的弹幕,它早就滚出屏了",
i+1, ch.start, r.Time)
}
}
}
if carriedTotal == 0 {
t.Fatal("这么密的弹幕不可能完全没有需要续画的块,续画逻辑没生效")
}
if fresh != len(records) {
t.Fatalf("新出现的弹幕数不对:%d,期望 %d", fresh, len(records))
}
}
// 块与块必须首尾相接,而且每条弹幕的 10 秒停留窗口要在所有块里被完整画满:
// 中间只要有缺口,观众就会看到那条弹幕闪一下又消失
func TestSplitDanmuChunksCoversEveryShowWindow(t *testing.T) {
// 每 0.5 秒一条:10 秒窗口里同时有 20 条,续画远放得下,不会触发「挤掉最早的几条」
records := make([]danmuRecord, 600)
for i := range records {
records[i] = danmuRecord{Time: float64(i) * 0.5, UID: 1, Name: "甲", Content: "弹幕", Lane: i % 10}
}
chunks := splitDanmuChunks(records, 0, 0) chunks := splitDanmuChunks(records, 0, 0)
for i := 1; i < len(chunks); i++ { for i := 1; i < len(chunks); i++ {
@@ -58,15 +105,47 @@ func TestSplitDanmuChunksLosesNoDanmu(t *testing.T) {
} }
} }
// 按 writeChunkAss 的可见区间算法检查:每条弹幕在它所在的那一块里都要有完整的显示区间 for _, r := range records {
covered := r.Time
for _, ch := range chunks { for _, ch := range chunks {
for _, r := range ch.records { vs, ve, ok := danmuVisibleInChunk(r, 0, ch)
if vs := math.Max(r.Time, ch.start); vs > r.Time+1e-9 { if !ok {
t.Fatalf("时间 %.3f 的弹幕在所在块里从 %.3f 才开始,开头被块头截掉了", r.Time, vs) continue
} }
if ve := math.Min(r.Time+danmuShowSeconds, ch.end); ve <= r.Time+1e-9 { if vs > covered+1e-9 {
t.Fatalf("时间 %.3f 的弹幕在所有块里都没有显示区间,会被整条丢掉", r.Time) t.Fatalf("时间 %.3f 的弹幕在 %.3f~%.3f 之间没被画出来(前后两块没接上)",
r.Time, covered, vs)
} }
if ve > covered {
covered = ve
}
}
if covered < r.Time+danmuShowSeconds-1e-9 {
t.Fatalf("时间 %.3f 的弹幕只画到 %.3f,10 秒停留窗口没画满", r.Time, covered)
}
}
}
// 同一时刻挤了一堆弹幕(时间戳重复)时块尾会和块头重合,那种块渲染出来一帧都没有,
// 拼进成片就是一段黑屏/缺帧
func TestSplitDanmuChunksNeverProducesEmptySpan(t *testing.T) {
records := make([]danmuRecord, 0, 400)
for i := 0; i < 400; i++ {
records = append(records, danmuRecord{
Time: 5, UID: 1, Name: "甲", Content: "同一时刻", Lane: i % 10, Width: 100,
})
}
chunks := splitDanmuChunks(records, 0, 0)
if len(chunks) < 2 {
t.Fatalf("400 条应当分成多块,实际 %d 块", len(chunks))
}
for i, ch := range chunks {
if math.IsInf(ch.end, 1) {
continue
}
if ch.end <= ch.start {
t.Fatalf("第 %d 块的时间范围是空的(%.3f ~ %.3f),渲染出来一帧都没有",
i+1, ch.start, ch.end)
} }
} }
} }
+21 -11
View File
@@ -7,8 +7,13 @@ import (
"testing" "testing"
) )
// 按最坏情况(每条弹幕一个不同用户、每个都带头像)渲染一块,返回这一块的命令行长度。 // 按最坏情况(每条弹幕一个不同用户、每个都带头像、文字宽度顶满整屏)渲染一块,
// 长度从 renderChunk 自己的自检错误里读出来;没触发自检就返回 (0, false)。 // 返回这一块的命令行长度。长度从 renderChunk 自己的自检错误里读出来;
// 没触发自检就返回 (0, false)。
//
// 头像路径必须按 renderChunk 真正会拿到的形态造 —— 预缩放目录里的短相对路径
// (见 prepareAvatarInputs),不是 avatar_cache 里的长绝对路径;用后者量出来的是
// 一个早就不存在的旧版长度,会把上限算得过分保守。
// //
// 线上的那次事故就是这里没守住:单块 200 条时实测 48288 字符,远超 Windows 的 32767, // 线上的那次事故就是这里没守住:单块 200 条时实测 48288 字符,远超 Windows 的 32767,
// 而 exec 只回一句 "The filename or extension is too long",看不出跟弹幕数量有关。 // 而 exec 只回一句 "The filename or extension is too long",看不出跟弹幕数量有关。
@@ -19,12 +24,10 @@ func chunkCommandLenProbe(t *testing.T, n int) (length int, over bool) {
records := make([]danmuRecord, 0, n) records := make([]danmuRecord, 0, n)
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
uid := int64(1000000 + i) uid := int64(1000000 + i)
// 头像路径按真实缓存目录的形态造(uid_12位摘要.png),故意做得和线上一样长 avatars[uid] = chunkAvatarDir + "/" + fmt.Sprintf("%d.png", i+1)
avatars[uid] = filepath.Join(dir, avatarCacheDir,
fmt.Sprintf("%d_%012x.png", uid, i*0x9e3779b1))
records = append(records, danmuRecord{ records = append(records, danmuRecord{
Time: float64(i) * 0.05, UID: uid, Name: "观众甲", Time: float64(i) * 0.05, UID: uid, Name: "观众甲",
Content: "这条弹幕稍微长一点测试宽度", Lane: i % 20, Width: 480, Content: "这条弹幕稍微长一点测试宽度", Lane: i % 100, Width: 1920,
}) })
} }
ctx := &burnContext{ ctx := &burnContext{
@@ -66,11 +69,18 @@ func TestRenderChunkReportsTooLongCommand(t *testing.T) {
} }
} }
// 硬上限本身要留出足够的余量:按实测每条约 241 字符,上限条数不能逼近 32767 // 硬上限本身要留出余量:真跑一遍「上限条数 + 最坏情况」,而不是拿一个会过期的
// 单条估算值乘一乘。换了头像方案(绝对路径 → 预缩放短路径)、滤镜写法或
// 单条 overlay 的表达式以后,这个用例会跟着一起变,不会悄悄失效
func TestChunkHardLimitLeavesHeadroom(t *testing.T) { func TestChunkHardLimitLeavesHeadroom(t *testing.T) {
const perRecordWorstCase = 241 // 2026-09-26 真机实测(每条一个不同用户) if length, over := chunkCommandLenProbe(t, maxDanmuPerChunkHard); over {
if est := maxDanmuPerChunkHard*perRecordWorstCase + 200; est > maxCommandLineLen { t.Fatalf("硬上限 %d 条的最坏情况实测 %d 字符,已经超过安全值 %d",
t.Fatalf("硬上限 %d 条按最坏情况估算要 %d 字符,超过了安全值 %d", maxDanmuPerChunkHard, length, maxCommandLineLen)
maxDanmuPerChunkHard, est, maxCommandLineLen) }
// 再往上加一成也不该顶破安全值,防止上限被悄悄调高到刚好卡线
headroom := maxDanmuPerChunkHard / 10
if length, over := chunkCommandLenProbe(t, maxDanmuPerChunkHard+headroom); over {
t.Fatalf("上限再高 %d 条(共 %d 条)就顶破了安全值 %d 字符,余量太小",
headroom, maxDanmuPerChunkHard+headroom, length)
} }
} }
+22 -4
View File
@@ -137,6 +137,7 @@ type configData struct {
MergeDelDanmu bool `json:"mergeDelDanmu"` // 合并完成后删除弹幕文件 MergeDelDanmu bool `json:"mergeDelDanmu"` // 合并完成后删除弹幕文件
MergeOutputDir string `json:"mergeOutputDir"` // 合并完成后成片移动到该文件夹,留空不移动 MergeOutputDir string `json:"mergeOutputDir"` // 合并完成后成片移动到该文件夹,留空不移动
MergeBitrate int `json:"mergeBitrate"` // 合并成片的目标码率(Kbps),0 表示使用默认质量 MergeBitrate int `json:"mergeBitrate"` // 合并成片的目标码率(Kbps),0 表示使用默认质量
MergeFps int `json:"mergeFps"` // 合并成片的输出帧率,0 表示跟随源片(只在高于源片时生效,源片帧率不会被降下来)
DanmuFontSize int `json:"danmuFontSize"` // 合并时弹幕文字的字号(像素,头像随字号等比缩放),0 表示按画面分辨率自动 DanmuFontSize int `json:"danmuFontSize"` // 合并时弹幕文字的字号(像素,头像随字号等比缩放),0 表示按画面分辨率自动
// 新添加主播的默认录制档位(留空/非法按第二高)与自定义时的码率上限 // 新添加主播的默认录制档位(留空/非法按第二高)与自定义时的码率上限
@@ -154,7 +155,8 @@ type configData struct {
NotifyLiveOn bool `json:"notifyLiveOn"` // 开播时推送 NotifyLiveOn bool `json:"notifyLiveOn"` // 开播时推送
NotifyLiveOff bool `json:"notifyLiveOff"` // 下播时推送 NotifyLiveOff bool `json:"notifyLiveOff"` // 下播时推送
NotifyRecordStart bool `json:"notifyRecordStart"` // 开始录制直播视频时推送 NotifyRecordStart bool `json:"notifyRecordStart"` // 开始录制直播视频时推送
NotifyMergeDone bool `json:"notifyMergeDone"` // 自动合并完成时推送 NotifyMergeDone bool `json:"notifyMergeDone"` // 合并完成时推送
NotifyMergeFail bool `json:"notifyMergeFail"` // 合并失败时推送(用户手动停止不算失败,不推)
MeowEnabled bool `json:"meowEnabled"` // MeoW 推送的渠道开关 MeowEnabled bool `json:"meowEnabled"` // MeoW 推送的渠道开关
MeowNickname string `json:"meowNickname"` // MeoW 昵称,推送地址里的路径参数 MeowNickname string `json:"meowNickname"` // MeoW 昵称,推送地址里的路径参数
@@ -224,6 +226,9 @@ const (
// 录播视频默认的封装格式。原版(orzogc/acfunlive)用 config.Output 决定扩展名,默认就是 mp4 // 录播视频默认的封装格式。原版(orzogc/acfunlive)用 config.Output 决定扩展名,默认就是 mp4
const defaultRecordFormat = "mp4" const defaultRecordFormat = "mp4"
// 合并输出帧率的上限。再高只会让渲染更慢,对弹幕流畅度没有意义
const maxMergeFps = 240
// 支持的录播封装格式。录制用的是 -c copy,只换容器不转码,所以这些都只是封装差别 // 支持的录播封装格式。录制用的是 -c copy,只换容器不转码,所以这些都只是封装差别
var recordFormats = map[string]struct{}{ var recordFormats = map[string]struct{}{
"mp4": {}, "mp4": {},
@@ -261,6 +266,7 @@ var config = configData{
RecordSubdir: defaultRecordSubdir, RecordSubdir: defaultRecordSubdir,
MergeSubdir: defaultMergeSubdir, MergeSubdir: defaultMergeSubdir,
NotifyEnabled: true, NotifyEnabled: true,
NotifyMergeFail: true,
} }
// Windows 下不能用作文件(夹)名的保留字 // Windows 下不能用作文件(夹)名的保留字
@@ -322,10 +328,11 @@ func loadConfig() {
checkErr(err) checkErr(err)
if json.Valid(data) { if json.Valid(data) {
// 老设置文件里没有 notifyEnabled 这个字段,默认按「开启」处理, // 老设置文件里没有 notifyEnabled / notifyMergeFail 这两个字段,默认按「开启」处理,
// 否则升级后已经配好的推送会被静默关掉。写在 Unmarshal 前面, // 否则升级后已经配好的推送会被静默关掉。写在 Unmarshal 前面,
// 文件里显式写了 false 时仍会被覆盖成 false // 文件里显式写了 false 时仍会被覆盖成 false
config.NotifyEnabled = true config.NotifyEnabled = true
config.NotifyMergeFail = true
err = json.Unmarshal(data, &config) err = json.Unmarshal(data, &config)
checkErr(err) checkErr(err)
} else { } else {
@@ -335,11 +342,18 @@ func loadConfig() {
// 命令行合成模式(-danmuburn)也读取设置文件,让 ffmpeg 路径、成片目录等配置生效 // 命令行合成模式(-danmuburn)也读取设置文件,让 ffmpeg 路径、成片目录等配置生效
func loadConfigForDanmuburn() { func loadConfigForDanmuburn() {
if *configDir == "" { // 这条路径不走 initialize(),exeDir 得自己定:头像缓存放哪、ffmpeg 去哪找都看它。
// 漏掉的话 cacheDir 会变成「当前工作目录/avatar_cache」,换目录跑就会重新下一遍头像,
// 更糟的是旧版把头像绝对路径直接写进滤镜,拿到相对路径时 ffmpeg(工作目录是弹幕目录)
// 就找不到文件、整块渲染失败
if exeDir == "" {
if exePath, err := os.Executable(); err == nil { if exePath, err := os.Executable(); err == nil {
*configDir = filepath.Dir(exePath) exeDir = filepath.Dir(exePath)
} }
} }
if *configDir == "" {
*configDir = exeDir
}
if *configDir == "" { if *configDir == "" {
return return
} }
@@ -422,6 +436,10 @@ func checkConfig() {
lPrintErrf("%s里的danmuFontSize不能小于 0(0 表示按画面分辨率自动)", configFile) lPrintErrf("%s里的danmuFontSize不能小于 0(0 表示按画面分辨率自动)", configFile)
os.Exit(1) os.Exit(1)
} }
if config.MergeFps < 0 || config.MergeFps > maxMergeFps {
lPrintErrf("%s里的mergeFps只能在 0(跟随源片)到 %d 之间", configFile, maxMergeFps)
os.Exit(1)
}
if config.DefaultBitrate < 0 { if config.DefaultBitrate < 0 {
lPrintErrf("%s里的defaultBitrate不能小于 0", configFile) lPrintErrf("%s里的defaultBitrate不能小于 0", configFile)
os.Exit(1) os.Exit(1)
+82
View File
@@ -7,6 +7,88 @@ import (
"testing" "testing"
) )
// 「合并失败」推送是新增的开关,老设置文件里没有这个字段 —— 升级后不能静默关掉,
// 也不能把用户显式写的 false 顶回 true。同一个坑「推送渠道」已经踩过一次
func TestLoadConfigPresetsNotifyMergeFail(t *testing.T) {
dir := t.TempDir()
savedConfig := config
savedLocation := configFileLocation
oldDir := configDir
if configDir == nil {
configDir = new(string)
}
defer func() {
config = savedConfig
configFileLocation = savedLocation
configDir = oldDir
}()
*configDir = dir
configFileLocation = filepath.Join(dir, configFile)
// 老文件:连 notifyEnabled 都没有,两个开关都该是默认开
content, _ := json.Marshal(map[string]any{"uids": []any{}, "mergeFps": 60})
if err := os.WriteFile(configFileLocation, content, 0644); err != nil {
t.Fatal(err)
}
config.NotifyMergeFail = false // 先弄脏,确认是 Unmarshal 之前被顶回 true 的
loadConfig()
if !config.NotifyMergeFail {
t.Error("老设置文件里没有 notifyMergeFail 时应当按开启处理")
}
if config.MergeFps != 60 {
t.Errorf("mergeFps 没读进来:%d", config.MergeFps)
}
// 文件里显式写了 false,必须尊重
content, _ = json.Marshal(map[string]any{"notifyMergeFail": false, "mergeFps": 0})
if err := os.WriteFile(configFileLocation, content, 0644); err != nil {
t.Fatal(err)
}
config.NotifyMergeFail = true
loadConfig()
if config.NotifyMergeFail {
t.Error("文件里显式写的 notifyMergeFail=false 被顶回了 true")
}
if config.MergeFps != 0 {
t.Errorf("mergeFps 应当是 0,实际 %d", config.MergeFps)
}
}
// 命令行合成模式(-danmuburn)不走 initialize(),exeDir 必须由 loadConfigForDanmuburn 自己定:
// 漏掉的话头像缓存就变成「当前工作目录/avatar_cache」—— 换个目录跑要重新下一遍头像,
// 更糟的是旧版把头像一个相对路径写进滤镜,而 ffmpeg 的工作目录是弹幕目录,
// 于是找不到文件、整块渲染失败(报错只显示「打不开 avatar_cache\xxx.png」)
func TestLoadConfigForDanmuburnSetsExeDir(t *testing.T) {
savedConfig := config
savedExe, savedLocation := exeDir, configFileLocation
oldDir, oldRecordDir := configDir, recordDir
if configDir == nil {
configDir = new(string)
}
if recordDir == nil {
recordDir = new(string)
}
defer func() {
config = savedConfig
exeDir, configFileLocation = savedExe, savedLocation
configDir, recordDir = oldDir, oldRecordDir
}()
exeDir = ""
*configDir = ""
loadConfigForDanmuburn()
if exeDir == "" {
t.Fatal("loadConfigForDanmuburn 应当把 exeDir 定下来,否则头像缓存会落到当前工作目录")
}
if !filepath.IsAbs(avatarCachePath()) {
t.Fatalf("头像缓存路径应当是绝对路径,实际 %q", avatarCachePath())
}
if filepath.Dir(configFileLocation) != exeDir {
t.Errorf("没指定 -config 时设置文件应当在程序目录,实际 %q(exeDir=%q)", configFileLocation, exeDir)
}
}
// 改设置会重新读取配置(web UI 保存、或直接改 config.json), // 改设置会重新读取配置(web UI 保存、或直接改 config.json),
// 这一步绝不能删掉正在进行的合并的临时文件 —— 否则合并会当场失败。 // 这一步绝不能删掉正在进行的合并的临时文件 —— 否则合并会当场失败。
func TestReloadConfigKeepsMergeTempFiles(t *testing.T) { func TestReloadConfigKeepsMergeTempFiles(t *testing.T) {
+284 -71
View File
@@ -76,24 +76,36 @@ const danmuShowSeconds = 10.0
// 弹幕一多整条命令就会超限且报错完全看不出原因,所以超过这个数量就分块渲染再拼接。 // 弹幕一多整条命令就会超限且报错完全看不出原因,所以超过这个数量就分块渲染再拼接。
const maxDanmuPerChunk = 80 const maxDanmuPerChunk = 80
// 单块条数的硬上限。块尾会把「显示时间跨过块尾」的弹幕一并并入, // 单块绘制条数的硬上限(跨块续画 + 本块新出现的条数之和)。
// 弹幕密集时(10 秒停留窗口里塞进几百条)单块就会膨胀到几百条 ——
// 一条弹幕在 -filter_complex 里约占 110 字节,几百条足以顶破上面那条命令行上限,
// 表现同样是「合并失败且看不出原因」,所以并入也要有个头。
// 到上限就把块尾收到下一条弹幕出现之前,让剩下的弹幕落到下一块。
// //
// 上限值按最坏情况实测定的:一块里每条弹幕都来自不同用户时,每条要占 // 每条弹幕在 -filter_complex 里占一段 overlay(约 105 字符),每个用户还要占一行
// 「-i 头像路径 + scale 行 + overlay 行」约 241 字符(2026-09-26 在真机量的), // 「-i 头像」(预缩放后是 acfunlive_chunk_av/12.png 这种相对路径,约 30 字符)。
// 120 条 ≈ 28864 字符,给中文路径和其它参数留出 3KB 余量;原来写 200 条时 // 最坏情况——每块里每条弹幕都来自不同用户、文字宽度顶满整屏——实测每条约 130 字符,
// 实测 48288 字符,已经远超 Windows 的 32767,线上就是这里崩的。 // 200 条 ≈ 26600 字符,离 Windows 的 32767 和自检阈值 30000 都还有余量
const maxDanmuPerChunkHard = 120 // (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_" const chunkTempPrefix = "acfunlive_chunk_"
// 预缩放头像的临时文件夹(放在录播目录下)。名字带 chunkTempPrefix,
// 文件列表和启动清理都会认它;合并结束时整个目录删掉。
const chunkAvatarDir = chunkTempPrefix + "av"
// Windows 的 CreateProcess 限制整个命令行 32767 字符(Linux 是单个参数 128KB)。 // Windows 的 CreateProcess 限制整个命令行 32767 字符(Linux 是单个参数 128KB)。
// 留一点余量,超了提早报错。filter 内容已经走 -filter_complex_script 文件, // 留一点余量,超了提早报错。这一版 ffmpeg(n9.0.2)**没有** -filter_complex_script,
// 这里管的是剩下的参数 —— 每个头像一个 -i 路径是大头 // 滤镜图只能整段放在命令行里,所以滤镜里的路径长度直接决定单块能画多少条弹幕
const maxCommandLineLen = 30000 const maxCommandLineLen = 30000
// 估算交给 exec 的命令行总长:每个参数按「一个空格 + 内容」算 // 估算交给 exec 的命令行总长:每个参数按「一个空格 + 内容」算
@@ -113,7 +125,8 @@ const estimateAudioKbps = 192
type danmuChunk struct { type danmuChunk struct {
start float64 // 在视频时间轴上的起点(秒) start float64 // 在视频时间轴上的起点(秒)
end float64 // 终点(秒),math.Inf(1) 表示一直到视频结尾 end float64 // 终点(秒),math.Inf(1) 表示一直到视频结尾
records []danmuRecord // 属于这一块的弹幕 carried int // records 里前多少条是上一块尾还在屏上的续画
records []danmuRecord // 这一块要画的弹幕:续画 + 本块新出现的
} }
// 渲染分块时共用的参数 // 渲染分块时共用的参数
@@ -123,10 +136,44 @@ type burnContext struct {
outFile string outFile string
offset float64 offset float64
meta danmuMeta meta danmuMeta
avatars map[int64]string avatars map[int64]string // uid → 预缩放头像的相对路径(见 prepareAvatarInputs)
bitrate int bitrate int
dir string dir string
encoder 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 倍) // 头像直径,与文字的实际墨迹高度一致(字号是 em 框,墨迹约为 0.8 倍)
@@ -145,6 +192,23 @@ func avatarGap(fontSize int) int {
return fontSize / 6 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 { func shortSide(w, h int) int {
@@ -320,12 +384,13 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
} }
// 弹幕文件的坐标系和视频对不上时,统一到视频坐标系(见 unifyDanmuSpace); // 弹幕文件的坐标系和视频对不上时,统一到视频坐标系(见 unifyDanmuSpace);
// 顺便拿到源片码率,用于「合成码率填 0」时的自动码率 // 顺便拿到源片码率(用于「合成码率填 0」时的自动码率)和帧率(用于输出帧率)
sourceKbps := 0 sourceKbps := 0
sourceFps := 0.0
if burnMode { if burnMode {
var vw, vh int info := probeVideoStream(ffmpegFile, videoFile)
vw, vh, sourceKbps = probeVideoStream(ffmpegFile, videoFile) sourceKbps, sourceFps = info.kbps, info.fps
unifyDanmuSpace(&meta, vw, vh) unifyDanmuSpace(&meta, info.width, info.height)
lPrintf("视频编码器优先顺序:%s", strings.Join(availableEncoders(ffmpegFile), " → ")) lPrintf("视频编码器优先顺序:%s", strings.Join(availableEncoders(ffmpegFile), " → "))
} }
// 字号在坐标系统一之后再定,此时 meta.PlayRes 就是成片画布 // 字号在坐标系统一之后再定,此时 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{ ctx := &burnContext{
ffmpegFile: ffmpegFile, ffmpegFile: ffmpegFile,
videoFile: videoFile, videoFile: videoFile,
outFile: outFile, outFile: outFile,
offset: offset, offset: offset,
meta: meta, meta: meta,
avatars: avatarPaths, avatars: avatarInputs,
bitrate: bitrate, bitrate: bitrate,
dir: filepath.Dir(jsonlFile), dir: workDir,
outFps: outFps,
layerFps: layerFps(config.MergeFps),
} }
chunks := splitDanmuChunks(records, offset, videoDuration) chunks := splitDanmuChunks(records, offset, videoDuration)
@@ -403,8 +489,8 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
return ctx.burnSingle(records) return ctx.burnSingle(records)
} }
lPrintf("共 %d 条弹幕,%d 个用户的头像可用;超过单次渲染上限(%d 条),分 %d 块渲染后拼接", lPrintf("共 %d 条弹幕,%d 个用户的头像可用;超过单块绘制上限(%d 条),分 %d 块渲染后拼接",
len(records), len(avatarPaths), maxDanmuPerChunk, len(chunks)) len(records), len(avatarPaths), maxDanmuPerChunkHard, len(chunks))
return ctx.burnChunked(chunks) return ctx.burnChunked(chunks)
} }
@@ -580,20 +666,28 @@ func unifyDanmuSpace(meta *danmuMeta, vw, vh int) bool {
return true return true
} }
// 探测视频流的分辨率和码率(Kbps),拿不到的项返回 0 // 视频流的关键参数。拿不到的项一律为 0
func probeVideoStream(ffmpegFile, path string) (int, int, int) { 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) cmd := exec.Command(ffmpegFile, "-hide_banner", "-i", path)
var buf bytes.Buffer var buf bytes.Buffer
cmd.Stderr = &buf cmd.Stderr = &buf
// 没指定输出文件时 ffmpeg 会返回非 0,但流信息已经打印出来了 // 没指定输出文件时 ffmpeg 会返回非 0,但流信息已经打印出来了
_ = cmd.Run() _ = cmd.Run()
width, height, kbps := 0, 0, 0 var info videoStreamInfo
for _, line := range strings.Split(buf.String(), "\n") { for _, line := range strings.Split(buf.String(), "\n") {
// 容器的总码率(含音频)先记下来,视频流自己没写码率时用它 // 容器的总码率(含音频)先记下来,视频流自己没写码率时用它
if strings.Contains(line, "Duration:") { if strings.Contains(line, "Duration:") {
if m := containerBitrateRe.FindStringSubmatch(line); len(m) == 2 { if m := containerBitrateRe.FindStringSubmatch(line); len(m) == 2 {
kbps, _ = strconv.Atoi(m[1]) info.kbps, _ = strconv.Atoi(m[1])
} }
continue continue
} }
@@ -602,14 +696,17 @@ func probeVideoStream(ffmpegFile, path string) (int, int, int) {
continue continue
} }
if m := videoSizeRe.FindStringSubmatch(line); len(m) == 3 { if m := videoSizeRe.FindStringSubmatch(line); len(m) == 3 {
width, _ = strconv.Atoi(m[1]) info.width, _ = strconv.Atoi(m[1])
height, _ = strconv.Atoi(m[2]) info.height, _ = strconv.Atoi(m[2])
} }
if m := videoBitrateRe.FindStringSubmatch(line); len(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 ( var (
@@ -618,6 +715,8 @@ var (
videoBitrateRe = regexp.MustCompile(`, (\d+) kb/s`) videoBitrateRe = regexp.MustCompile(`, (\d+) kb/s`)
// 容器总码率,形如「Duration: 00:02:55.07, start: 0.000000, bitrate: 1767 kb/s」 // 容器总码率,形如「Duration: 00:02:55.07, start: 0.000000, bitrate: 1767 kb/s」
containerBitrateRe = regexp.MustCompile(`bitrate: (\d+) kb/s`) containerBitrateRe = regexp.MustCompile(`bitrate: (\d+) kb/s`)
// 帧率,形如「1920x1080, 30 fps, 30 tbr, 1k tbn」
videoFpsRe = regexp.MustCompile(`, (\d+(?:\.\d+)?) fps`)
) )
// 合成码率填 0(默认)时的算法:源片码率 + 弹幕余量。 // 合成码率填 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)) part := filepath.Join(c.dir, fmt.Sprintf("%spart_%04d%s", chunkTempPrefix, i, ext))
// 先登记再渲染:中途失败或被打断时,defer 里的清理也能把这个半成品删掉 // 先登记再渲染:中途失败或被打断时,defer 里的清理也能把这个半成品删掉
parts = append(parts, part) parts = append(parts, part)
lPrintf("正在渲染第 %d/%d 块(%s ~ %s,%d 条弹幕)", lPrintf("正在渲染第 %d/%d 块(%s ~ %s,共画 %d 条,其中续画 %d 条)",
i+1, len(chunks), formatSeconds(ch.start), formatSeconds(ch.end), len(ch.records)) i+1, len(chunks), formatSeconds(ch.start), formatSeconds(ch.end), len(ch.records), ch.carried)
base := bytesBase base := bytesBase
if err := c.renderChunk(ch, assName, part, true, func(outSec, speed, fps float64, size int64) { 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 return nil
} }
// 把弹幕按时间切成若干块,块的时间范围首尾相接,保证视频不会缺帧 // 把弹幕按时间切成若干块,块的时间范围首尾相接,保证视频不会缺帧。
//
// 每块画两组东西:
// 1. 「续画」:上一块尾还在屏上的弹幕。一条弹幕停留 10 秒,块边界经常从它中间切过去。
// 不续画的话,每个块头屏幕上就只剩刚出现的那几条,要 10 秒才重新填满 ——
// 看起来就是「弹幕突然加速,然后一段时间屏幕上没有弹幕」;
// 2. 起播时间落在本块里的弹幕。
//
// 两组合起来不能超过 maxDanmuPerChunkHard(命令行长度限制),所以块的长度是
// 「预算 ÷ 弹幕密度」自己算出来的。续画把预算吃光时挤掉最早的那几条
// (它们已经滚到左边缘快出画了),但每块一定留 minNewDanmuPerChunk 条新弹幕 ——
// 否则块长会退化成 0,无限细分下去。
//
// 块尾统一取「下一条弹幕的起播时间」:这样每条弹幕只会在唯一一块里算「新出现」,
// 而它显示期间跨过的后面几块会把它当续画接着画,一帧都不缺。
// maxEnd 大于 0 时表示视频总时长,块尾不会超过它 // maxEnd 大于 0 时表示视频总时长,块尾不会超过它
func splitDanmuChunks(records []danmuRecord, offset, maxEnd float64) []danmuChunk { func splitDanmuChunks(records []danmuRecord, offset, maxEnd float64) []danmuChunk {
n := len(records) 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}} 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) chunks := make([]danmuChunk, 0, n/maxDanmuPerChunk+1)
i := 0 // records[:next] 都已经在更早的块里「新出现」过了
next := 0
start := 0.0 start := 0.0
for i < n { for next < n {
limit := i + maxDanmuPerChunk // 续画:起播时间在 (start-10, start) 的弹幕,此刻都还在屏上
if limit >= n { from := next
chunks = append(chunks, danmuChunk{start: start, end: math.Inf(1), records: records[i:]}) for from > 0 && appear(from-1) > start-danmuShowSeconds {
break from--
} }
// 块尾取第 limit 条弹幕显示结束的时刻 carried := records[from:next]
end := records[limit-1].Time + offset + danmuShowSeconds room := maxDanmuPerChunkHard - len(carried)
if room < minNewDanmuPerChunk {
// 爆点:续画快把预算占满了。挤掉最早的那几条(已经快到左边缘),
// 换出空间放新弹幕,同时保证块一定往前走
drop := minNewDanmuPerChunk - room
if drop > len(carried) {
drop = len(carried)
}
carried = carried[drop:]
room = minNewDanmuPerChunk
}
hi := next + room
if hi > n {
hi = n
}
end := math.Inf(1)
if hi < n {
end = appear(hi)
if maxEnd > 0 && end > maxEnd { if maxEnd > 0 && end > maxEnd {
end = maxEnd end = maxEnd
} }
if end <= start { if end <= start {
end = start + danmuShowSeconds // 同一时刻挤了一堆弹幕(时间戳重复)时块尾会和块头重合,那种块
} // 渲染出来一帧都没有。往后挪一点,被跳过的弹幕下一块会当续画接着画
// 起点早于块尾的弹幕(显示会跨过块尾)也并入这一块,但不许超过硬上限 end = start + minChunkSpan
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]}) recs := make([]danmuRecord, 0, len(carried)+hi-next)
i = j 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 start = end
} }
return chunks return chunks
} }
// 块尾和块头挨得太近时至少拉开的时长。太短的话这一块渲染出来只有 0~1 帧,拼进成片会缺帧
const minChunkSpan = 0.5
// 为分块渲染生成只包含该块弹幕的字幕文件(相对块起点计时) // 为分块渲染生成只包含该块弹幕的字幕文件(相对块起点计时)
func writeChunkAss(path string, meta danmuMeta, chunk danmuChunk, offset float64, avW, avGap int) error { func writeChunkAss(path string, meta danmuMeta, chunk danmuChunk, offset float64, avW, avGap int) error {
f, err := os.Create(path) 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 text := r.Name + "(" + strconv.FormatInt(r.UID, 10) + "):" + r.Content
y := meta.FontSize * r.Lane 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, fmt.Fprintf(f, dialogueNickTmpl,
assTime(int64((visStart-chunk.start)*1e9)), assTime(int64((visEnd-chunk.start)*1e9)), assTime(int64((visStart-chunk.start)*1e9)), assTime(int64((visEnd-chunk.start)*1e9)),
strings.ReplaceAll(r.Name, ",", " "), r.UID, strings.ReplaceAll(r.Name, ",", " "), r.UID,
meta.PlayResX+avW+avGap, y, -r.Width, y, text) x1, y, x2, y, text)
} }
return nil 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 必须共用这一个判断 —— 只统计、不裁剪的话,同一个人在这块里 // 统计头像用量和生成 overlay 必须共用这一个判断 —— 只统计、不裁剪的话,同一个人在这块里
// 会被 split 出多余的份数,那些输出 pad 没人接收,ffmpeg 直接以 EINVAL 退出 // 会被 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) args = append(args, "-i", c.videoFile)
} else { } else {
args = append(args, "-f", "lavfi", "-i", args = append(args, "-f", "lavfi", "-i",
fmt.Sprintf("color=c=black@0:s=%dx%d:d=%.2f:r=30,format=rgba", 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.meta.PlayResX, c.meta.PlayResY, c.chunkDuration(chunk)+0.5, c.layerRate()))
} }
for _, uid := range uidOrder { for _, uid := range uidOrder {
args = append(args, "-i", c.avatars[uid]) 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{} fc := &strings.Builder{}
prev := "0:v" prev := "0:v"
// 低帧率源先提到目标帧率(重复帧):字幕和头像都在这一步之后渲染,弹幕才会
// 按下发的新帧率一格格重画。放在字幕之后加等于没做 —— 弹幕已经按源片帧率
// 画好了,再复制帧还是原来那几个位置
if burnMode && c.outFps > 0 {
fmt.Fprintf(fc, "[0:v]fps=%d[fps0];\n", c.outFps)
prev = "fps0"
}
if burnMode { if burnMode {
fmt.Fprintf(fc, "[%s]subtitles=filename='%s'[b0];\n", prev, assBase) fmt.Fprintf(fc, "[%s]subtitles=filename='%s'[b0];\n", prev, assBase)
prev = "b0" prev = "b0"
} }
// 把每个头像缩放到弹幕字号对应的直径,同一个头像被多条弹幕使用时用 split 复制 // 头像已经预缩放成 ad×ad(见 prepareAvatarInputs),这里直接用输入 pad;
// 只有同一个人的头像在这一块里要用到多条弹幕时才 split 复制
ad := avatarSize(c.meta.FontSize) ad := avatarSize(c.meta.FontSize)
gap := avatarGap(c.meta.FontSize) gap := avatarGap(c.meta.FontSize)
for _, uid := range uidOrder { for _, uid := range uidOrder {
if uidCount[uid] <= 1 {
continue
}
idx := uidInput[uid] 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{} outs := &strings.Builder{}
for j := 0; j < uidCount[uid]; j++ { for j := 0; j < uidCount[uid]; j++ {
fmt.Fprintf(outs, "[av%d_%d]", idx, 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()) fmt.Fprintf(fc, "[%d:v]split=%d%s;\n", idx, uidCount[uid], outs.String())
}
} }
uidCopy := make(map[int64]int) // 每个头像当前用掉的份数 uidCopy := make(map[int64]int) // 每个头像当前用掉的份数
for n, r := range chunk.records { for n, r := range chunk.records {
@@ -907,21 +1061,21 @@ func (c *burnContext) renderChunk(chunk danmuChunk, assBase, out string, cut boo
if !visible { if !visible {
continue continue
} }
avLabel := strconv.Itoa(idx) avPad := strconv.Itoa(idx) + ":v"
if uidCount[r.UID] > 1 { 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]++ uidCopy[r.UID]++
} }
t0 := r.Time + c.offset t0 := r.Time + c.offset
// 头像在文字左侧并留出间隔:ASS 里的文字左边缘是这里加 ad+gap,头像跟着文字一起滚动。 // 头像在文字左侧并留出间隔:ASS 里的文字左边缘是这里加 ad+gap,头像跟着文字一起滚动。
// 位置公式 x = 画面宽 - (画面宽+总宽)*(t-t0)/10,这里 t 是块内时间,把 t0 也换算到块内坐标 // 公式与文字层的 danmuTextX 同源(见 avatarXExpr):这里 t 是块内时间,
// 把 t0 也换算到块内坐标,两边在任意时刻算出来的位置都严丝合缝
total := ad + gap + r.Width total := ad + gap + r.Width
shift := chunk.start - t0 x := avatarXExpr(c.meta.PlayResX, total, 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 // 头像与文字墨迹垂直居中 y := c.meta.FontSize*r.Lane + (c.meta.FontSize-ad)/2 // 头像与文字墨迹垂直居中
outLabel := fmt.Sprintf("s%d", n) outLabel := fmt.Sprintf("s%d", n)
fmt.Fprintf(fc, "[%s][av%s]overlay=x='%s':y=%d:enable='between(t,%.3f,%.3f)'[%s];\n", fmt.Fprintf(fc, "[%s][%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, avPad, x, y, visStart-chunk.start, visEnd-chunk.start, outLabel)
prev = outLabel prev = outLabel
} }
if burnMode { if burnMode {
@@ -1736,6 +1890,65 @@ func qsvEncoderAvailable(ffmpegFile, encoder string) bool {
return ok 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 // 把头像居中裁方、缩放并圆形裁切,输出带透明通道的 PNG
func circleAvatar(src image.Image, size int) image.Image { func circleAvatar(src image.Image, size int) image.Image {
sb := src.Bounds() sb := src.Bounds()
+101
View File
@@ -0,0 +1,101 @@
package main
import (
"math"
"path/filepath"
"testing"
)
// 输出帧率只在目标的**上面**才生效:源片本来就 60fps 时填 30 只会把画面压得更卡,
// 源片帧率探测不到(0)时也不动,免得把高帧率源押到某个值
func TestOutputFpsOnlyRaises(t *testing.T) {
for _, c := range []struct {
target int
source float64
want int
}{
{0, 30, 0}, // 没填:跟随源片
{60, 0, 0}, // 源片帧率探测不到:不动
{60, 30, 60}, // 15/24/30fps 录播里弹幕一格一格跳,提上去
{60, 60, 0}, // 已经一样高:不动
{60, 59.94, 60}, // 29.97/59.94 这类小数帧率要当成「低一点点」
{30, 60, 0}, // 降帧没意义
{-1, 30, 0}, // 非法值当成没填
} {
if got := outputFps(c.target, c.source); got != c.want {
t.Errorf("outputFps(%d, %g) = %d,期望 %d", c.target, c.source, got, c.want)
}
}
}
// 透明头像层模式没有源片可跟:设置填了就按填的,没填固定 30
func TestLayerFpsFallback(t *testing.T) {
if got := layerFps(0); got != defaultLayerFps {
t.Errorf("layerFps(0) = %d,期望 %d", got, defaultLayerFps)
}
if got := layerFps(60); got != 60 {
t.Errorf("layerFps(60) = %d,期望 60", got)
}
// 手工构造的 burnContext 没填这个字段时也不能把 r=0 交给 ffmpeg
// (color 源拿 r=0 会直接 EINVAL,症状是 exit status 0xffffffea)
ctx := &burnContext{}
if got := ctx.layerRate(); got != defaultLayerFps {
t.Errorf("没填 layerFps 的 burnContext 应当回落到 %d,实际 %d", defaultLayerFps, got)
}
ctx.layerFps = 25
if got := ctx.layerRate(); got != 25 {
t.Errorf("填了 layerFps 就按填的来,实际 %d", got)
}
}
// 低帧率源设了输出帧率时,成片帧率真的会提上去:
// fps 滤镜必须加在字幕/头像之前,弹幕才会按新帧率重绘;
// 加在之后只是把已经画好的几帧复制一遍,看起来还是卡
func TestRenderChunkRaisesFrameRate(t *testing.T) {
ffmpegFile := getFFmpeg()
if ffmpegFile == "" {
t.Skip("找不到 ffmpeg,跳过")
}
withJobFlags(t, true, false, false)
dir := t.TempDir()
src := filepath.Join(dir, "src.mp4")
if err := runFFmpegWith(ffmpegFile, []string{
"-y", "-f", "lavfi", "-i", "color=c=black:s=160x120:d=1:r=15",
"-c:v", "libx264", "-pix_fmt", "yuv420p", src,
}, dir, nil); err != nil {
t.Skipf("生成 15fps 测试视频失败,跳过:%v", err)
}
chunk := danmuChunk{start: 0, end: 1, records: []danmuRecord{
{Time: 0.1, UID: 7, Name: "甲", Content: "提帧测试", Lane: 0, Width: 100},
}}
ctx := &burnContext{
ffmpegFile: ffmpegFile,
videoFile: src,
outFile: filepath.Join(dir, "out.mp4"),
meta: danmuMeta{PlayResX: 160, PlayResY: 120, FontSize: 20},
avatars: map[int64]string{},
dir: dir,
encoder: "libx264",
outFps: outputFps(30, 15),
}
if ctx.outFps != 30 {
t.Fatalf("15fps 源设 30 应当产出 outFps=30,实际 %d", ctx.outFps)
}
assName := chunkTempPrefix + "fps_test.ass"
if err := writeChunkAss(filepath.Join(dir, assName), ctx.meta, chunk, 0,
avatarSize(ctx.meta.FontSize), avatarGap(ctx.meta.FontSize)); err != nil {
t.Fatal(err)
}
out := filepath.Join(dir, "chunk.mp4")
if err := ctx.renderChunk(chunk, assName, out, true, nil); err != nil {
t.Fatalf("提帧渲染不该失败:%v", err)
}
info := probeVideoStream(ffmpegFile, out)
if math.Abs(info.fps-30) > 0.5 {
t.Fatalf("成片帧率应当是 30,实际 %.2f", info.fps)
}
}
+1
View File
@@ -656,6 +656,7 @@ func mergeHandler(w http.ResponseWriter, r *http.Request) {
return return
default: default:
lPrintErrf("手动合并失败:%v", err) lPrintErrf("手动合并失败:%v", err)
notifyMergeFailure(guessUIDByFileName(plan.out), plan.out, err)
} }
mergeRun.Lock() mergeRun.Lock()
mergeRun.lastErr = msg mergeRun.lastErr = msg
+2
View File
@@ -261,6 +261,8 @@ func notifyEventTitle(event string) string {
return "开始录制" return "开始录制"
case notifyEventMergeDone: case notifyEventMergeDone:
return "合并完成" return "合并完成"
case notifyEventMergeFail:
return "合并失败"
} }
return "AcFun 直播录制" return "AcFun 直播录制"
} }
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"math"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
)
// avatarXExpr 产出的表达式就长这样:A-(A+T)*(t<±S>)/D
var avatarXExprRE = regexp.MustCompile(`^(\d+)-\((\d+)\+(\d+)\)\*\(t([+-]\d+\.\d+)\)/(\d+)$`)
// 按块内时间 t 求 avatarXExpr 的值。只认它自己产出的这一种形式;
// 表达式一改,这里的正则就会失配,用例会明确报出来
func evalAvatarXExpr(t *testing.T, expr string, tInChunk float64) float64 {
t.Helper()
m := avatarXExprRE.FindStringSubmatch(expr)
if m == nil {
t.Fatalf("头像 x 表达式的形式变了,这个用例要跟着改:%s", expr)
}
playResX, _ := strconv.Atoi(m[1])
base, _ := strconv.Atoi(m[2])
total, _ := strconv.Atoi(m[3])
shift, _ := strconv.ParseFloat(m[4], 64)
den, _ := strconv.ParseFloat(m[5], 64)
if base != playResX {
t.Fatalf("表达式里两个底数不一致:%s", expr)
}
if den != danmuShowSeconds {
t.Fatalf("表达式的分母 %g 和弹幕停留时长 %g 对不上:%s", den, danmuShowSeconds, expr)
}
// %+.3f 打出来的是带符号的偏移量,所以这里是 t + shift
return float64(playResX) - float64(playResX+total)*(tInChunk+shift)/den
}
// 文字层(libass 的 \move)和头像层(overlay 的 x 表达式)必须是同一条运动轨迹。
// 两边一旦各算各的,被块边界截断的弹幕就会出现「文字已经走到左边、头像还在半路上」——
// 线上看到的就是「头像在中途偏离弹幕轨迹」
func TestAvatarAndTextShareSameMotion(t *testing.T) {
const playResX, avPad, total = 1920, 40, 600
for _, t0 := range []float64{0, 0.5, 123.456} {
// 块起点取三种典型位置:就是弹幕起播那一刻、在它之前 3 秒(被截断的续画)、
// 以及一个很大的时间轴位置(验证大数下公式也不跑偏)
for _, chunkStart := range []float64{t0, t0 - 3, t0 + 2000} {
expr := avatarXExpr(playResX, total, chunkStart-t0)
for _, tAbs := range []float64{t0, t0 + 0.01, t0 + 1.5, t0 + 9.999, t0 + danmuShowSeconds} {
wantText := danmuTextX(playResX, avPad, total, tAbs, t0)
gotAv := evalAvatarXExpr(t, expr, tAbs-chunkStart)
if math.Abs(gotAv-(wantText-float64(avPad))) > 0.5 {
t.Fatalf("t0=%.3f 块起点=%.3f t=%.3f 时文字左边缘 %.2f、头像 %.2f,"+
"两者应当相差一个头像宽度 %d", t0, chunkStart, tAbs, wantText, gotAv, avPad)
}
}
}
}
// 出发点和终点也要对:起播瞬间文字左边缘刚好在画面右侧外一个「头像+间隔」处
// (对应的头像左边缘正好贴着画面右边界),停留结束时整条刚好完全离开左边界 ——
// 起点是 playResX 出头的那点偏移,终点是 -total,行程正好 playResX+total
if got := danmuTextX(playResX, avPad, total, 0, 0); got != float64(playResX+avPad) {
t.Errorf("起播瞬间文字左边缘应当是 %d,实际 %.2f", playResX+avPad, got)
}
if got := danmuAvatarX(playResX, avPad, total, 0, 0); got != float64(playResX) {
t.Errorf("起播瞬间头像左边缘应当贴着画面右边界 %d,实际 %.2f", playResX, got)
}
if got := danmuTextX(playResX, avPad, total, danmuShowSeconds, 0); got != float64(avPad-total) {
t.Errorf("停留结束时文字左边缘应当是 %d,实际 %.2f", avPad-total, got)
}
if got := danmuAvatarX(playResX, avPad, total, danmuShowSeconds, 0); got != float64(-total) {
t.Errorf("停留结束时头像左边缘(整条的左端)应当是 %d,实际 %.2f", -total, got)
}
}
// 写进 ASS 的 \move 起止点必须按**绝对时间**算,而不是「截断之后的这一小段」:
// 拿截断后的时长当整段行程(旧实现:终点写死 -Width)等于把 10 秒的路挤进零点几秒,
// 文字会飞过去;分块渲染里 66.4% 的弹幕都被截断过,所以这个错法影响面极大
func TestWriteChunkAssKeepsAbsoluteTrajectory(t *testing.T) {
dir := t.TempDir()
meta := danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 42}
avW, avGap := avatarSize(meta.FontSize), avatarGap(meta.FontSize)
total := avW + avGap + 480
// 块从 100 秒开始,弹幕起播在 96 秒 —— 块头把它切掉了,只剩最后 6 秒可见
chunk := danmuChunk{start: 100, end: 130, records: []danmuRecord{
{Time: 96, UID: 7, Name: "甲", Content: "被块头截断的", Lane: 2, Width: 480},
}}
path := filepath.Join(dir, "chunk.ass")
if err := writeChunkAss(path, meta, chunk, 0, avW, avGap); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
m := regexp.MustCompile(`\\move\((-?\d+),(-?\d+),(-?\d+),(-?\d+)\)`).FindStringSubmatch(string(data))
if m == nil {
t.Fatalf("ASS 里找不到 \\move:\n%s", data)
}
nums := make([]int, 4)
for i := range nums {
nums[i], _ = strconv.Atoi(m[i+1])
}
t0 := 96.0
wantX1 := int(math.Round(danmuTextX(meta.PlayResX, avW+avGap, total, chunk.start, t0)))
wantX2 := int(math.Round(danmuTextX(meta.PlayResX, avW+avGap, total, t0+danmuShowSeconds, t0)))
if nums[0] != wantX1 || nums[2] != wantX2 {
t.Errorf("\\move 起止点应当是 (%d → %d),实际 (%d → %d);"+
"终点按截断后的时长算会把 10 秒的路挤进 %.1f 秒里,文字直接飞过去",
wantX1, wantX2, nums[0], nums[2], chunk.end-t0)
}
// 终点必须是「走完全程、整条离开左边界」的位置,不是文字宽度本身
if nums[2] != avW+avGap-total {
t.Errorf("停留结束时的终点应当是 %d(= avPad - total),实际 %d", avW+avGap-total, nums[2])
}
if strings.Contains(string(data), "{") && !strings.Contains(string(data), `\an7`) {
t.Error("对话行丢了 \\an7 定位锚点")
}
}
+22 -1
View File
@@ -18,11 +18,12 @@ const (
notifyEventLiveOff = "liveOff" notifyEventLiveOff = "liveOff"
notifyEventRecordStart = "recordStart" notifyEventRecordStart = "recordStart"
notifyEventMergeDone = "mergeDone" notifyEventMergeDone = "mergeDone"
notifyEventMergeFail = "mergeFail"
) )
// 推送到 webhook 的请求体 // 推送到 webhook 的请求体
type notifyPayload struct { type notifyPayload struct {
Event string `json:"event"` // 事件名:liveOn、liveOff、recordStart、mergeDone Event string `json:"event"` // 事件名:liveOn、liveOff、recordStart、mergeDone、mergeFail
UID int `json:"uid"` // 主播 uid UID int `json:"uid"` // 主播 uid
Name string `json:"name"` // 主播名字 Name string `json:"name"` // 主播名字
Title string `json:"title"` // 直播标题(下播和合并完成时可能为空) Title string `json:"title"` // 直播标题(下播和合并完成时可能为空)
@@ -81,6 +82,7 @@ type notifySetting struct {
liveOff bool liveOff bool
recordStart bool recordStart bool
mergeDone bool mergeDone bool
mergeFail bool
} }
func currentNotifySetting() notifySetting { func currentNotifySetting() notifySetting {
@@ -100,6 +102,7 @@ func currentNotifySetting() notifySetting {
liveOff: config.NotifyLiveOff, liveOff: config.NotifyLiveOff,
recordStart: config.NotifyRecordStart, recordStart: config.NotifyRecordStart,
mergeDone: config.NotifyMergeDone, mergeDone: config.NotifyMergeDone,
mergeFail: config.NotifyMergeFail,
} }
} }
@@ -114,6 +117,8 @@ func (n notifySetting) enabled(event string) bool {
return n.recordStart return n.recordStart
case notifyEventMergeDone: case notifyEventMergeDone:
return n.mergeDone return n.mergeDone
case notifyEventMergeFail:
return n.mergeFail
} }
return false return false
} }
@@ -201,6 +206,22 @@ func sendNotify(event string, uid int, name, title, url, file, text string) {
} }
} }
// 合并失败时推送。nil、「弹幕文件里没有弹幕」和被手动停止都不算失败,不会推送。
// fileName 用于正文里标出是哪一场,拿不到就留空
func notifyMergeFailure(uid int, fileName string, err error) {
if err == nil || err == errNoDanmu || err == errJobStopped {
return
}
text := "合并失败"
if uid > 0 {
text += "(" + longID(uid) + " 的录播和弹幕)"
} else if fileName != "" {
text += "(" + fileName + ")"
}
text += "\n原因:" + err.Error()
sendNotify(notifyEventMergeFail, uid, nameForLog(uid), "", "", fileName, text)
}
// 把事件 POST 到 webhook,同时返回 HTTP 状态码(失败时也会带上,方便测试接口显示细节) // 把事件 POST 到 webhook,同时返回 HTTP 状态码(失败时也会带上,方便测试接口显示细节)
func postNotify(addr, token string, p notifyPayload) (int, error) { func postNotify(addr, token string, p notifyPayload) (int, error) {
data, err := json.Marshal(p) data, err := json.Marshal(p)
+73
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"errors"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -438,6 +439,7 @@ func TestNotifyEventTitle(t *testing.T) {
notifyEventLiveOff: "下播提醒", notifyEventLiveOff: "下播提醒",
notifyEventRecordStart: "开始录制", notifyEventRecordStart: "开始录制",
notifyEventMergeDone: "合并完成", notifyEventMergeDone: "合并完成",
notifyEventMergeFail: "合并失败",
} { } {
if got := notifyEventTitle(event); got != want { if got := notifyEventTitle(event); got != want {
t.Errorf("notifyEventTitle(%q) = %q,期望 %q", event, got, want) t.Errorf("notifyEventTitle(%q) = %q,期望 %q", event, got, want)
@@ -445,6 +447,77 @@ func TestNotifyEventTitle(t *testing.T) {
} }
} }
// 合并失败要推送,而「正常结束 / 文件里没有弹幕 / 用户自己点的停止」都不许推:
// 前两种本来就不算失败,第三种推「合并失败」会误导
func TestNotifyMergeFailureFanOut(t *testing.T) {
savedConfig := config
resetMeowBadNick()
defer func() {
config = savedConfig
resetMeowBadNick()
}()
var mu sync.Mutex
var hooks []notifyPayload
webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var p notifyPayload
_ = json.NewDecoder(r.Body).Decode(&p)
mu.Lock()
hooks = append(hooks, p)
mu.Unlock()
_, _ = w.Write([]byte("ok"))
}))
defer webhook.Close()
countHooks := func() int {
mu.Lock()
defer mu.Unlock()
return len(hooks)
}
waitHooks := func(want int) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) && countHooks() < want {
time.Sleep(20 * time.Millisecond)
}
}
config.NotifyEnabled = true
config.NotifyURL = webhook.URL
config.NotifyMergeFail = true
config.NotifyMergeDone = false
notifyMergeFailure(280178, "2026-09-25 01-00-00 甲_弹幕合并.mp4", errors.New("磁盘写满了"))
waitHooks(1)
mu.Lock()
got := append([]notifyPayload{}, hooks...)
mu.Unlock()
if len(got) != 1 {
t.Fatalf("合并失败应当推送一条,实际 %d 条", len(got))
}
if got[0].Event != notifyEventMergeFail {
t.Errorf("事件名应当是 %s,实际 %s", notifyEventMergeFail, got[0].Event)
}
if !strings.Contains(got[0].Text, "磁盘写满了") {
t.Errorf("正文里应当带上失败原因,实际:%s", got[0].Text)
}
notifyMergeFailure(280178, "x.mp4", nil)
notifyMergeFailure(280178, "x.mp4", errNoDanmu)
notifyMergeFailure(280178, "x.mp4", errJobStopped)
time.Sleep(300 * time.Millisecond)
if n := countHooks(); n != 1 {
t.Errorf("正常结束 / 没有弹幕 / 手动停止都不该推送,实际共 %d 条", n)
}
config.NotifyMergeFail = false
notifyMergeFailure(280178, "x.mp4", errors.New("又失败了"))
time.Sleep(300 * time.Millisecond)
if n := countHooks(); n != 1 {
t.Errorf("没勾「合并失败」时不该推送,实际共 %d 条", n)
}
}
// 两个渠道的启用条件必须互相独立,并且都受各自开关控制 // 两个渠道的启用条件必须互相独立,并且都受各自开关控制
func TestNotifySettingChannelReady(t *testing.T) { func TestNotifySettingChannelReady(t *testing.T) {
cases := []struct { cases := []struct {
+17
View File
@@ -104,10 +104,27 @@ func TestCleanTempFiles(t *testing.T) {
writeTestFile(t, filepath.Join(recordSaveDir(), "2026-09-25 01-00-00 甲.mp4"), "视频"), writeTestFile(t, filepath.Join(recordSaveDir(), "2026-09-25 01-00-00 甲.mp4"), "视频"),
writeTestFile(t, filepath.Join(mergeSaveDir(), "2026-09-25 01-00-00 甲_弹幕合并.mp4"), "成片"), writeTestFile(t, filepath.Join(mergeSaveDir(), "2026-09-25 01-00-00 甲_弹幕合并.mp4"), "成片"),
} }
// 预缩放头像是个目录,也要一起清掉;别顺手把普通的录播子目录当成临时文件夹删了
avatarDir := filepath.Join(recordSaveDir(), chunkAvatarDir)
if err := os.MkdirAll(avatarDir, 0755); err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(avatarDir, "1.png"), "头像")
normalDir := filepath.Join(recordSaveDir(), "别的文件夹")
if err := os.MkdirAll(normalDir, 0755); err != nil {
t.Fatal(err)
}
cleanTempFiles(recordSaveDir()) cleanTempFiles(recordSaveDir())
cleanTempFiles(mergeSaveDir()) cleanTempFiles(mergeSaveDir())
if _, err := os.Stat(avatarDir); !os.IsNotExist(err) {
t.Errorf("残留的预缩放头像文件夹应该被整个清掉:%s", chunkAvatarDir)
}
if _, err := os.Stat(normalDir); err != nil {
t.Errorf("名字不带临时前缀的目录不该被删:%s", filepath.Base(normalDir))
}
for _, p := range append(append([]string{}, staleRecord...), staleMerge) { for _, p := range append(append([]string{}, staleRecord...), staleMerge) {
if _, err := os.Stat(p); !os.IsNotExist(err) { if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Errorf("残留的临时文件应该被清掉:%s", filepath.Base(p)) t.Errorf("残留的临时文件应该被清掉:%s", filepath.Base(p))
+10 -1
View File
@@ -358,10 +358,19 @@ func cleanTempFiles(dir string) {
return return
} }
for _, e := range entries { for _, e := range entries {
name := e.Name()
// 预缩放头像是个目录(chunkAvatarDir),别被整个跳过
if e.IsDir() { if e.IsDir() {
if strings.HasPrefix(name, chunkTempPrefix) {
path := filepath.Join(dir, name)
if err := os.RemoveAll(path); err == nil {
lPrintln("清理上次留下的临时文件夹:" + name)
} else {
lPrintWarnf("临时文件夹 %s 现在删不掉,下次启动会自动清理:%v", path, err)
}
}
continue continue
} }
name := e.Name()
if !strings.HasPrefix(name, chunkTempPrefix) && !strings.HasSuffix(name, partSuffix) { if !strings.HasPrefix(name, chunkTempPrefix) && !strings.HasSuffix(name, partSuffix) {
continue continue
} }
+24 -8
View File
@@ -381,6 +381,7 @@ const webuiHTML = `<!DOCTYPE html>
</div> </div>
</div> </div>
<div class="f"><label>合成码率(Kbps,0 = 源片码率 + 500)</label><input type="text" id="mergeBitrate" inputmode="numeric" placeholder="例如 1500,填 0 自动"></div> <div class="f"><label>合成码率(Kbps,0 = 源片码率 + 500)</label><input type="text" id="mergeBitrate" inputmode="numeric" placeholder="例如 1500,填 0 自动"></div>
<div class="f"><label>输出帧率(0 = 跟随源片)</label><input type="text" id="mergeFps" inputmode="numeric" placeholder="0 跟随源片;填例如 60 让低帧率录播里的弹幕更顺滑"></div>
<div class="f"><label>弹幕字号(像素,0 = 自动按画面分辨率)</label><input type="text" id="danmuFontSize" inputmode="numeric" placeholder="0 自动,或填例如 42;头像随字号一起缩放"></div> <div class="f"><label>弹幕字号(像素,0 = 自动按画面分辨率)</label><input type="text" id="danmuFontSize" inputmode="numeric" placeholder="0 自动,或填例如 42;头像随字号一起缩放"></div>
</div> </div>
<div class="hint">文本框改完按回车或点右上角「保存设置」生效,不做这一步的话 3 秒后会被刷回原值。</div> <div class="hint">文本框改完按回车或点右上角「保存设置」生效,不做这一步的话 3 秒后会被刷回原值。</div>
@@ -436,8 +437,9 @@ const webuiHTML = `<!DOCTYPE html>
<label class="sw"><input type="checkbox" id="notifyLiveOff"><span class="sw-i"></span>下播</label> <label class="sw"><input type="checkbox" id="notifyLiveOff"><span class="sw-i"></span>下播</label>
<label class="sw"><input type="checkbox" id="notifyRecordStart"><span class="sw-i"></span>开始录制</label> <label class="sw"><input type="checkbox" id="notifyRecordStart"><span class="sw-i"></span>开始录制</label>
<label class="sw"><input type="checkbox" id="notifyMergeDone"><span class="sw-i"></span>合并完成</label> <label class="sw"><input type="checkbox" id="notifyMergeDone"><span class="sw-i"></span>合并完成</label>
<label class="sw"><input type="checkbox" id="notifyMergeFail"><span class="sw-i"></span>合并失败</label>
</div> </div>
<div class="hint">推送失败只记警告,不影响录制。</div> <div class="hint">推送失败只记警告,不影响录制。「合并失败」不含「文件里没有弹幕」和你自己点的手动停止。</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-h"><h2>渠道一 · QQ 机器人</h2><span class="sub">自建机器人,走 HTTP POST JSON</span></div> <div class="card-h"><h2>渠道一 · QQ 机器人</h2><span class="sub">自建机器人,走 HTTP POST JSON</span></div>
@@ -551,8 +553,8 @@ var token = localStorage.getItem("acfunlive_token") || ""; // 管理口令,存
var authed = false; // 是否已通过口令校验,未通过时停止轮询 var authed = false; // 是否已通过口令校验,未通过时停止轮询
var tokenLoaded = false; var tokenLoaded = false;
// 设置表单里的字段,元素 id 与 /api/config 的字段名一致 // 设置表单里的字段,元素 id 与 /api/config 的字段名一致
var settingFields = ["directory","autoMerge","mergeDelVideo","mergeDelDanmu","mergeOutputDir","mergeBitrate","danmuFontSize","defaultQualityPref","defaultBitrate","recordSubdir","mergeSubdir","ffmpegPath","recordFormat","notifyEnabled","notifyUrl","notifyToken","notifyGroup","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","meowEnabled","meowNickname","meowMsgType","meowHtmlHeight","meowImgUrl","webExternal"]; var settingFields = ["directory","autoMerge","mergeDelVideo","mergeDelDanmu","mergeOutputDir","mergeBitrate","mergeFps","danmuFontSize","defaultQualityPref","defaultBitrate","recordSubdir","mergeSubdir","ffmpegPath","recordFormat","notifyEnabled","notifyUrl","notifyToken","notifyGroup","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","notifyMergeFail","meowEnabled","meowNickname","meowMsgType","meowHtmlHeight","meowImgUrl","webExternal"];
var numberFields = {mergeBitrate:1, danmuFontSize:1, defaultBitrate:1, meowHtmlHeight:1}; var numberFields = {mergeBitrate:1, mergeFps:1, danmuFontSize:1, defaultBitrate:1, meowHtmlHeight:1};
// 用户改过、还没提交的字段。定时刷新会拿服务端的值填表单,不记下这些字段的话, // 用户改过、还没提交的字段。定时刷新会拿服务端的值填表单,不记下这些字段的话,
// 刚填好的数字 3 秒后会被刷回旧值,看起来就像「改了又自己恢复」 // 刚填好的数字 3 秒后会被刷回旧值,看起来就像「改了又自己恢复」
var dirty = {}; var dirty = {};
@@ -716,6 +718,7 @@ async function refresh(){
setVal("directory", st.directory||""); setVal("directory", st.directory||"");
setVal("mergeOutputDir", st.mergeOutputDir||""); setVal("mergeOutputDir", st.mergeOutputDir||"");
setVal("mergeBitrate", st.mergeBitrate||0); setVal("mergeBitrate", st.mergeBitrate||0);
setVal("mergeFps", st.mergeFps||0);
setVal("danmuFontSize", st.danmuFontSize||0); setVal("danmuFontSize", st.danmuFontSize||0);
setVal("defaultQualityPref", st.defaultQualityPref||"second"); setVal("defaultQualityPref", st.defaultQualityPref||"second");
setVal("defaultBitrate", st.defaultBitrate||0); setVal("defaultBitrate", st.defaultBitrate||0);
@@ -732,6 +735,7 @@ async function refresh(){
setChk("notifyLiveOff", st.notifyLiveOff); setChk("notifyLiveOff", st.notifyLiveOff);
setChk("notifyRecordStart", st.notifyRecordStart); setChk("notifyRecordStart", st.notifyRecordStart);
setChk("notifyMergeDone", st.notifyMergeDone); setChk("notifyMergeDone", st.notifyMergeDone);
setChk("notifyMergeFail", st.notifyMergeFail);
setChk("meowEnabled", st.meowEnabled); setChk("meowEnabled", st.meowEnabled);
setVal("meowNickname", st.meowNickname||""); setVal("meowNickname", st.meowNickname||"");
setVal("meowMsgType", st.meowMsgType||"text"); setVal("meowMsgType", st.meowMsgType||"text");
@@ -885,7 +889,7 @@ async function saveSettings(b, autoSave){
if(b) b.disabled = false; if(b) b.disabled = false;
} }
// 勾选类设置修改后立即保存,避免被定时刷新还原 // 勾选类设置修改后立即保存,避免被定时刷新还原
["autoMerge","mergeDelVideo","mergeDelDanmu","webExternal","notifyEnabled","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","meowEnabled"].forEach(function(id){ ["autoMerge","mergeDelVideo","mergeDelDanmu","webExternal","notifyEnabled","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","notifyMergeFail","meowEnabled"].forEach(function(id){
document.getElementById(id).addEventListener("change", function(){ dropDirty(id); saveSettings(null, true); }); document.getElementById(id).addEventListener("change", function(){ dropDirty(id); saveSettings(null, true); });
}); });
// 下拉框改完也立即保存 // 下拉框改完也立即保存
@@ -1705,6 +1709,7 @@ type webStatus struct {
MergeDelDanmu bool `json:"mergeDelDanmu"` MergeDelDanmu bool `json:"mergeDelDanmu"`
MergeOutputDir string `json:"mergeOutputDir"` MergeOutputDir string `json:"mergeOutputDir"`
MergeBitrate int `json:"mergeBitrate"` MergeBitrate int `json:"mergeBitrate"`
MergeFps int `json:"mergeFps"`
DanmuFontSize int `json:"danmuFontSize"` DanmuFontSize int `json:"danmuFontSize"`
DefaultQualityPref string `json:"defaultQualityPref"` DefaultQualityPref string `json:"defaultQualityPref"`
DefaultBitrate int `json:"defaultBitrate"` DefaultBitrate int `json:"defaultBitrate"`
@@ -1721,6 +1726,7 @@ type webStatus struct {
NotifyLiveOff bool `json:"notifyLiveOff"` NotifyLiveOff bool `json:"notifyLiveOff"`
NotifyRecordStart bool `json:"notifyRecordStart"` NotifyRecordStart bool `json:"notifyRecordStart"`
NotifyMergeDone bool `json:"notifyMergeDone"` NotifyMergeDone bool `json:"notifyMergeDone"`
NotifyMergeFail bool `json:"notifyMergeFail"`
MeowEnabled bool `json:"meowEnabled"` MeowEnabled bool `json:"meowEnabled"`
MeowNickname string `json:"meowNickname"` MeowNickname string `json:"meowNickname"`
MeowMsgType string `json:"meowMsgType"` MeowMsgType string `json:"meowMsgType"`
@@ -1767,6 +1773,7 @@ func statusHandler(w http.ResponseWriter, r *http.Request) {
MergeDelDanmu: config.MergeDelDanmu, MergeDelDanmu: config.MergeDelDanmu,
MergeOutputDir: config.MergeOutputDir, MergeOutputDir: config.MergeOutputDir,
MergeBitrate: config.MergeBitrate, MergeBitrate: config.MergeBitrate,
MergeFps: config.MergeFps,
DanmuFontSize: config.DanmuFontSize, DanmuFontSize: config.DanmuFontSize,
WebExternal: config.WebExternal, WebExternal: config.WebExternal,
RecordSubdir: config.RecordSubdir, RecordSubdir: config.RecordSubdir,
@@ -1783,6 +1790,7 @@ func statusHandler(w http.ResponseWriter, r *http.Request) {
NotifyLiveOff: config.NotifyLiveOff, NotifyLiveOff: config.NotifyLiveOff,
NotifyRecordStart: config.NotifyRecordStart, NotifyRecordStart: config.NotifyRecordStart,
NotifyMergeDone: config.NotifyMergeDone, NotifyMergeDone: config.NotifyMergeDone,
NotifyMergeFail: config.NotifyMergeFail,
MeowEnabled: config.MeowEnabled, MeowEnabled: config.MeowEnabled,
MeowNickname: config.MeowNickname, MeowNickname: config.MeowNickname,
MeowMsgType: normalizeMeowMsgType(config.MeowMsgType), MeowMsgType: normalizeMeowMsgType(config.MeowMsgType),
@@ -1850,6 +1858,7 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
MergeDelDanmu bool `json:"mergeDelDanmu"` MergeDelDanmu bool `json:"mergeDelDanmu"`
MergeOutputDir string `json:"mergeOutputDir"` MergeOutputDir string `json:"mergeOutputDir"`
MergeBitrate int `json:"mergeBitrate"` MergeBitrate int `json:"mergeBitrate"`
MergeFps int `json:"mergeFps"`
DanmuFontSize int `json:"danmuFontSize"` DanmuFontSize int `json:"danmuFontSize"`
DefaultQualityPref string `json:"defaultQualityPref"` DefaultQualityPref string `json:"defaultQualityPref"`
DefaultBitrate int `json:"defaultBitrate"` DefaultBitrate int `json:"defaultBitrate"`
@@ -1866,6 +1875,7 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
NotifyLiveOff bool `json:"notifyLiveOff"` NotifyLiveOff bool `json:"notifyLiveOff"`
NotifyRecordStart bool `json:"notifyRecordStart"` NotifyRecordStart bool `json:"notifyRecordStart"`
NotifyMergeDone bool `json:"notifyMergeDone"` NotifyMergeDone bool `json:"notifyMergeDone"`
NotifyMergeFail bool `json:"notifyMergeFail"`
MeowEnabled bool `json:"meowEnabled"` MeowEnabled bool `json:"meowEnabled"`
MeowNickname string `json:"meowNickname"` MeowNickname string `json:"meowNickname"`
MeowMsgType string `json:"meowMsgType"` MeowMsgType string `json:"meowMsgType"`
@@ -1893,6 +1903,10 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "danmuFontSize 不能小于 0(0 表示按画面分辨率自动)", http.StatusBadRequest) http.Error(w, "danmuFontSize 不能小于 0(0 表示按画面分辨率自动)", http.StatusBadRequest)
return return
} }
if req.MergeFps < 0 || req.MergeFps > maxMergeFps {
http.Error(w, fmt.Sprintf("mergeFps 只能在 0(跟随源片)到 %d 之间", maxMergeFps), http.StatusBadRequest)
return
}
if req.DefaultBitrate < 0 { if req.DefaultBitrate < 0 {
http.Error(w, "defaultBitrate 不能小于 0", http.StatusBadRequest) http.Error(w, "defaultBitrate 不能小于 0", http.StatusBadRequest)
return return
@@ -1961,6 +1975,7 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
config.MergeDelDanmu = req.MergeDelDanmu config.MergeDelDanmu = req.MergeDelDanmu
config.MergeOutputDir = req.MergeOutputDir config.MergeOutputDir = req.MergeOutputDir
config.MergeBitrate = req.MergeBitrate config.MergeBitrate = req.MergeBitrate
config.MergeFps = req.MergeFps
config.DanmuFontSize = req.DanmuFontSize config.DanmuFontSize = req.DanmuFontSize
config.DefaultQualityPref = normalizeQualityPref(req.DefaultQualityPref) config.DefaultQualityPref = normalizeQualityPref(req.DefaultQualityPref)
config.DefaultBitrate = req.DefaultBitrate config.DefaultBitrate = req.DefaultBitrate
@@ -1978,6 +1993,7 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
config.NotifyLiveOff = req.NotifyLiveOff config.NotifyLiveOff = req.NotifyLiveOff
config.NotifyRecordStart = req.NotifyRecordStart config.NotifyRecordStart = req.NotifyRecordStart
config.NotifyMergeDone = req.NotifyMergeDone config.NotifyMergeDone = req.NotifyMergeDone
config.NotifyMergeFail = req.NotifyMergeFail
config.MeowEnabled = req.MeowEnabled config.MeowEnabled = req.MeowEnabled
config.MeowNickname = meowNickname config.MeowNickname = meowNickname
config.MeowMsgType = meowMsgType config.MeowMsgType = meowMsgType
@@ -2001,14 +2017,14 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
lPrintln("外网访问设置已改变,重启 web UI 服务器") lPrintln("外网访问设置已改变,重启 web UI 服务器")
restartWebUI() restartWebUI()
} }
lPrintf("通过 web UI 修改了设置:目录 %q、自动合并 %v、合并删视频 %v、合并删弹幕 %v、成片目录 %q、合并码率 %d、新主播默认档位 %s、外网访问 %v、录播子文件夹 %q、成片子文件夹 %q、ffmpeg 路径 %q、录播格式 %q", lPrintf("通过 web UI 修改了设置:目录 %q、自动合并 %v、合并删视频 %v、合并删弹幕 %v、成片目录 %q、合并码率 %d、输出帧率 %d、新主播默认档位 %s、外网访问 %v、录播子文件夹 %q、成片子文件夹 %q、ffmpeg 路径 %q、录播格式 %q",
req.Directory, req.AutoMerge, req.MergeDelVideo, req.MergeDelDanmu, req.MergeOutputDir, req.Directory, req.AutoMerge, req.MergeDelVideo, req.MergeDelDanmu, req.MergeOutputDir,
req.MergeBitrate, qualityLogText(req.DefaultQualityPref, req.DefaultBitrate), req.WebExternal, req.MergeBitrate, req.MergeFps, qualityLogText(req.DefaultQualityPref, req.DefaultBitrate), req.WebExternal,
recordSubdir, mergeSubdir, config.FFmpegPath, recordFormat) recordSubdir, mergeSubdir, config.FFmpegPath, recordFormat)
lPrintf("推送设置:QQ 机器人渠道 %v(地址 %q、群号 %q)、MeoW 渠道 %v(昵称 %q、类型 %s、高度 %d);事件 开播 %v、下播 %v、开始录制 %v、合并完成 %v", lPrintf("推送设置:QQ 机器人渠道 %v(地址 %q、群号 %q)、MeoW 渠道 %v(昵称 %q、类型 %s、高度 %d);事件 开播 %v、下播 %v、开始录制 %v、合并完成 %v、合并失败 %v",
req.NotifyEnabled, notifyURL, config.NotifyGroup, req.MeowEnabled, meowNickname, meowMsgType, req.NotifyEnabled, notifyURL, config.NotifyGroup, req.MeowEnabled, meowNickname, meowMsgType,
clampMeowHTMLHeight(req.MeowHTMLHeight), clampMeowHTMLHeight(req.MeowHTMLHeight),
req.NotifyLiveOn, req.NotifyLiveOff, req.NotifyRecordStart, req.NotifyMergeDone) req.NotifyLiveOn, req.NotifyLiveOff, req.NotifyRecordStart, req.NotifyMergeDone, req.NotifyMergeFail)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("true")) _, _ = w.Write([]byte("true"))
+144
View File
@@ -2,11 +2,15 @@
package main package main
import ( import (
"bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"path/filepath"
"regexp"
"testing" "testing"
"time" "time"
) )
@@ -303,3 +307,143 @@ func TestAwaitAuthDelayStopsOnCancel(t *testing.T) {
t.Error("没被取消时应当正常等完再返回 true") t.Error("没被取消时应当正常等完再返回 true")
} }
} }
var (
webInputIDRe = regexp.MustCompile(`<(?:input|select|textarea)[^>]*\bid="([A-Za-z0-9_]+)"`)
webSettingFieldsRe = regexp.MustCompile(`var settingFields = \[([^\]]*)\]`)
webNumberFieldsRe = regexp.MustCompile(`var numberFields = \{([^}]*)\}`)
webQuotedRe = regexp.MustCompile(`"([A-Za-z0-9_]+)"`)
)
// 设置页里每个能改的输入框都必须在 settingFields 里登记:漏登记的后果是「改了、按了保存,
// 值却没被提交」,3 秒后刷新又被刷回原值 —— 页面看起来完全正常,也没有任何报错。
// 反过来,settingFields 里写的 id 如果页面上不存在,refresh 时就静默填不进去。
//
// settingFields / numberFields 都是页面里的 JS 变量(整页在 webuiHTML 这个原始串里),
// 所以这里直接把它们从串里抠出来比对,不另维护一份名单
func TestWebuiSettingFieldsMatchInputs(t *testing.T) {
ids := map[string]bool{}
for _, m := range webInputIDRe.FindAllStringSubmatch(webuiHTML, -1) {
ids[m[1]] = true
}
m := webSettingFieldsRe.FindStringSubmatch(webuiHTML)
if m == nil {
t.Fatal("没能在页面里找到 settingFields 列表")
}
settings := map[string]bool{}
for _, q := range webQuotedRe.FindAllStringSubmatch(m[1], -1) {
settings[q[1]] = true
if !ids[q[1]] {
t.Errorf("settingFields 里的 %q 在页面上找不到对应的输入框", q[1])
}
}
// 这些输入框刻意不进设置:要么是手动合并卡片自己的参数(随合并请求提交),
// 要么是路径选择弹窗 / 口令框的内部状态
notSettings := map[string]string{
"authTokenNew": "重新生成口令,走单独的接口",
"authTokenView": "只读展示当前口令",
"tokenInput": "页面解锁框,不是设置项",
"newuid": "添加主播,走主播接口",
"pickHidden": "路径选择弹窗的内部状态",
"pickPath": "路径选择弹窗的内部状态",
"mrgVideoPick": "手动合并:视频列表,随合并请求提交",
"mrgVideos": "手动合并:视频路径多行框,随合并请求提交",
"mrgDanmu": "手动合并:弹幕文件,随合并请求提交",
"mrgOffset": "手动合并:偏移秒数,随合并请求提交",
"mrgOut": "手动合并:成片名字,随合并请求提交",
}
for id := range ids {
if settings[id] {
continue
}
if _, ok := notSettings[id]; ok {
continue
}
t.Errorf("页面上的 %q 既不在 settingFields 里,也没说明为什么不算设置项:改了它不会被保存", id)
}
// numberFields 里的 id 必须都是设置项,否则 parseInt 的结果没人用
n := webNumberFieldsRe.FindStringSubmatch(webuiHTML)
if n == nil {
t.Fatal("没能在页面里找到 numberFields 列表")
}
for _, m := range regexp.MustCompile(`([A-Za-z0-9_]+)\s*:`).FindAllStringSubmatch(n[1], -1) {
if !settings[m[1]] {
t.Errorf("numberFields 里的 %q 不是设置项", m[1])
}
}
}
// 保存设置必须把新增字段真的落到 config 上:漏一个赋值就是「界面显示已保存、实际没生效」,
// 3 秒后刷新还被刷回原值,页面上完全看不出问题
func TestWebConfigHandlerSavesMergeFpsAndNotifyMergeFail(t *testing.T) {
dir := setupMergeTest(t)
savedConfig := config
savedLocation := configFileLocation
oldDir := configDir
if configDir == nil {
configDir = new(string)
}
defer func() {
config = savedConfig
configFileLocation = savedLocation
configDir = oldDir
}()
*configDir = dir
configFileLocation = filepath.Join(dir, configFile)
post := func(body map[string]any) string {
t.Helper()
data, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(data))
rec := httptest.NewRecorder()
webConfigHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("保存设置被拒(%d):%s", rec.Code, rec.Body.String())
}
return rec.Body.String()
}
base := func() map[string]any {
return map[string]any{
"directory": dir,
"ffmpegPath": testFFmpeg(t),
"recordSubdir": defaultRecordSubdir,
"mergeSubdir": defaultMergeSubdir,
}
}
config.MergeFps = 0
config.NotifyMergeFail = true
b := base()
b["mergeFps"] = 60
b["notifyMergeFail"] = false
post(b)
if config.MergeFps != 60 {
t.Errorf("mergeFps 没被保存下来:%d", config.MergeFps)
}
if config.NotifyMergeFail {
t.Error("notifyMergeFail=false 没被保存下来")
}
// 超范围(负数或大得离谱)必须挡在保存之前
for _, bad := range []int{-1, maxMergeFps + 1} {
config.MergeFps = 7
b = base()
b["mergeFps"] = bad
data, _ := json.Marshal(b)
req := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(data))
rec := httptest.NewRecorder()
webConfigHandler(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("mergeFps=%d 应当被拒(400),实际 %d", bad, rec.Code)
}
if config.MergeFps != 7 {
t.Errorf("mergeFps=%d 被拒后不该改到配置里,实际 %d", bad, config.MergeFps)
}
}
}