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。
375 lines
13 KiB
Go
375 lines
13 KiB
Go
package main
|
||
|
||
import (
|
||
"errors"
|
||
"math"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// 密集弹幕:每 0.02 秒一条,10 秒停留窗口里能塞进 500 条。
|
||
// 块尾扩张原来没有上限,会把窗口里的弹幕几乎全并进同一块
|
||
func denseRecords(n int) []danmuRecord {
|
||
records := make([]danmuRecord, 0, n)
|
||
for i := 0; i < n; i++ {
|
||
records = append(records, danmuRecord{
|
||
Time: float64(i) * 0.02,
|
||
UID: int64(i%5 + 1),
|
||
Name: "密集测试",
|
||
Content: "弹幕内容",
|
||
Lane: i % 10,
|
||
})
|
||
}
|
||
return records
|
||
}
|
||
|
||
// 单块条数必须被硬上限挡住(续画 + 本块新出现的条数一起算),否则 -filter_complex
|
||
// 会撑爆命令行长度。
|
||
//
|
||
// 「新出现」的那些条按顺序拼起来必须正好是原始列表:续画只是同一批弹幕在后面几块里
|
||
// 接着画,不能算成新弹幕,更不能有哪一条一次都没算成新出现(那就整条丢了)
|
||
func TestSplitDanmuChunksCapsBlockSize(t *testing.T) {
|
||
records := denseRecords(600)
|
||
chunks := splitDanmuChunks(records, 0, 0)
|
||
if len(chunks) < 2 {
|
||
t.Fatalf("这么多弹幕应当分成多块,实际 %d 块", len(chunks))
|
||
}
|
||
fresh := make([]danmuRecord, 0, len(records))
|
||
for i, ch := range chunks {
|
||
if n := len(ch.records); n > maxDanmuPerChunkHard {
|
||
t.Fatalf("第 %d 块有 %d 条弹幕,超过硬上限 %d", i+1, n, maxDanmuPerChunkHard)
|
||
}
|
||
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])
|
||
}
|
||
}
|
||
}
|
||
|
||
// 一条弹幕停留 10 秒,块边界经常从它中间切过去:分块器必须把它「续画」到后面几块里。
|
||
// 不续画的话每个块头屏幕上只剩刚出现的那几条,要 10 秒才重新填满 —— 观众看到的就是
|
||
// 「弹幕突然加速冲过去,然后一段时间屏幕上什么都没有」
|
||
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)
|
||
|
||
for i := 1; i < len(chunks); i++ {
|
||
if chunks[i].start != chunks[i-1].end {
|
||
t.Fatalf("第 %d 块和第 %d 块之间有空隙:%.3f -> %.3f",
|
||
i, i+1, chunks[i-1].end, chunks[i].start)
|
||
}
|
||
}
|
||
|
||
for _, r := range records {
|
||
covered := r.Time
|
||
for _, ch := range chunks {
|
||
vs, ve, ok := danmuVisibleInChunk(r, 0, ch)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if vs > covered+1e-9 {
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 回退成「只合并第一个分段」时,used 必须只剩第一段:
|
||
// 自动合并收尾按 used 删源视频,返回全部就等于把没进成片的后半场删掉
|
||
func TestPrepareForMergeOnlyMarksUsedSegments(t *testing.T) {
|
||
dir := t.TempDir()
|
||
a := filepath.Join(dir, "a.mp4")
|
||
b := filepath.Join(dir, "b.mp4")
|
||
for _, p := range []string{a, b} {
|
||
if err := os.WriteFile(p, []byte("x"), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
jsonl := filepath.Join(dir, "a.jsonl")
|
||
if err := os.WriteFile(jsonl, []byte("{}\n"), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// 用一个不存在的 ffmpeg:probeDuration 拿不到时长,触发「只合并第一个分段」的回退
|
||
segs := []recordSegment{{file: a, startNs: 0}, {file: b, startNs: int64(time.Second)}}
|
||
video, _, _, used, cleanup, err := prepareForMerge(filepath.Join(dir, "没有这个-ffmpeg.exe"), segs, jsonl, 0)
|
||
defer cleanup()
|
||
if err != nil {
|
||
t.Fatalf("回退不该报错:%v", err)
|
||
}
|
||
if video != a {
|
||
t.Fatalf("回退后应当合并第一段,实际 %s", video)
|
||
}
|
||
if len(used) != 1 || used[0] != a {
|
||
t.Fatalf("回退成只合并第一段时 used 应只含第一段,实际 %v", used)
|
||
}
|
||
}
|
||
|
||
// 直接改全局任务状态:测试里只碰 stopped 这一个字段,用完恢复
|
||
func setJobStopped(t *testing.T, v bool) {
|
||
t.Helper()
|
||
job.Lock()
|
||
prev := job.stopped
|
||
job.stopped = v
|
||
job.Unlock()
|
||
t.Cleanup(func() {
|
||
job.Lock()
|
||
job.stopped = prev
|
||
job.Unlock()
|
||
})
|
||
}
|
||
|
||
// 现场生成一个 1 秒的小视频分段
|
||
func makeTinyVideo(t *testing.T, ffmpegFile, path, color string) {
|
||
t.Helper()
|
||
if err := runFFmpegWith(ffmpegFile, []string{
|
||
"-y", "-f", "lavfi", "-i", "color=c=" + color + ":s=160x120:d=1:r=10",
|
||
"-c:v", "libx264", "-pix_fmt", "yuv420p", path,
|
||
}, filepath.Dir(path), nil); err != nil {
|
||
t.Skipf("生成测试分段失败,跳过:%v", err)
|
||
}
|
||
}
|
||
|
||
// 多段都拿到时长、成功拼接时,used 应当是全部分段(原来的删除行为不变)
|
||
func TestPrepareForMergeMarksAllSegmentsWhenJoined(t *testing.T) {
|
||
ffmpegFile := getFFmpeg()
|
||
if ffmpegFile == "" {
|
||
t.Skip("找不到 ffmpeg,跳过")
|
||
}
|
||
setJobStopped(t, false)
|
||
dir := t.TempDir()
|
||
a, b := filepath.Join(dir, "a.mp4"), filepath.Join(dir, "b.mp4")
|
||
makeTinyVideo(t, ffmpegFile, a, "red")
|
||
makeTinyVideo(t, ffmpegFile, b, "blue")
|
||
jsonl := filepath.Join(dir, "a.jsonl")
|
||
if err := os.WriteFile(jsonl, []byte(`{"playResX": 160, "playResY": 120, "fontSize": 20, "startTime": 0}`+"\n"+
|
||
`{"t": 0.2, "uid": 1, "name": "n", "avatar": "", "content": "c", "lane": 0, "width": 0}`+"\n"), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
segs := []recordSegment{{file: a, startNs: 0}, {file: b, startNs: int64(time.Second)}}
|
||
video, out, _, used, cleanup, err := prepareForMerge(ffmpegFile, segs, jsonl, 0)
|
||
defer cleanup()
|
||
if err != nil {
|
||
t.Fatalf("拼接两个正常分段不该报错:%v", err)
|
||
}
|
||
if video != filepath.Join(dir, chunkTempPrefix+"joined.mp4") {
|
||
t.Fatalf("应当用拼接后的文件合并,实际 %s", video)
|
||
}
|
||
if len(used) != 2 {
|
||
t.Fatalf("拼接成功时 used 应包含全部分段,实际 %v", used)
|
||
}
|
||
if _, err := os.Stat(out); err != nil {
|
||
t.Fatalf("重映射的弹幕文件应当存在:%v", err)
|
||
}
|
||
// cleanup 必须把中间文件收干净
|
||
cleanup()
|
||
if _, err := os.Stat(video); err == nil {
|
||
t.Fatalf("清理后拼接出来的中间视频还在:%s", video)
|
||
}
|
||
if _, err := os.Stat(out); err == nil {
|
||
t.Fatalf("清理后重映射的弹幕文件还在:%s", out)
|
||
}
|
||
}
|
||
|
||
// 拼接中途被停止:要报成 errJobStopped,并且把拼到一半的中间文件删掉
|
||
// (留在录播目录里既占几 GB 又带 chunkTempPrefix,界面文件列表里根本看不见)
|
||
func TestPrepareForMergeDropsJoinedWhenStopped(t *testing.T) {
|
||
ffmpegFile := getFFmpeg()
|
||
if ffmpegFile == "" {
|
||
t.Skip("找不到 ffmpeg,跳过")
|
||
}
|
||
setJobStopped(t, false)
|
||
dir := t.TempDir()
|
||
a, b := filepath.Join(dir, "a.mp4"), filepath.Join(dir, "b.mp4")
|
||
makeTinyVideo(t, ffmpegFile, a, "red")
|
||
makeTinyVideo(t, ffmpegFile, b, "blue")
|
||
jsonl := filepath.Join(dir, "a.jsonl")
|
||
if err := os.WriteFile(jsonl, []byte("{}\n"), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
joined := filepath.Join(dir, chunkTempPrefix+"joined.mp4")
|
||
if err := os.WriteFile(joined, []byte("拼到一半的内容"), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
setJobStopped(t, true)
|
||
|
||
segs := []recordSegment{{file: a, startNs: 0}, {file: b, startNs: int64(time.Second)}}
|
||
_, _, _, used, cleanup, err := prepareForMerge(ffmpegFile, segs, jsonl, 0)
|
||
defer cleanup()
|
||
if !errors.Is(err, errJobStopped) {
|
||
t.Fatalf("被停止时应当返回 errJobStopped,实际 %v", err)
|
||
}
|
||
if len(used) != 0 {
|
||
t.Fatalf("被停止时不该把任何分段算作已合并,实际 %v", used)
|
||
}
|
||
if _, err := os.Stat(joined); err == nil {
|
||
t.Fatalf("停止后拼到一半的中间文件应当被删掉:%s", joined)
|
||
}
|
||
}
|
||
|
||
// 任务处于「已请求停止」时,拼接失败要报成 errJobStopped,而不是「拼接失败」
|
||
func TestConcatChunksReportsStopped(t *testing.T) {
|
||
dir := t.TempDir()
|
||
f := filepath.Join(dir, "in.mp4")
|
||
if err := os.WriteFile(f, []byte("x"), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
out := filepath.Join(dir, "out.mp4")
|
||
|
||
setJobStopped(t, true)
|
||
|
||
err := concatChunks(filepath.Join(dir, "没有这个-ffmpeg.exe"), []string{f}, out, dir, nil)
|
||
if !errors.Is(err, errJobStopped) {
|
||
t.Fatalf("停止状态下拼接失败应当返回 errJobStopped,实际 %v", err)
|
||
}
|
||
}
|
||
|
||
// 没有停止请求时,拼接失败就是普通错误,别报成「已停止」
|
||
func TestConcatChunksReportsFailureWhenNotStopped(t *testing.T) {
|
||
dir := t.TempDir()
|
||
f := filepath.Join(dir, "in.mp4")
|
||
if err := os.WriteFile(f, []byte("x"), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
out := filepath.Join(dir, "out.mp4")
|
||
|
||
setJobStopped(t, false)
|
||
|
||
err := concatChunks(filepath.Join(dir, "没有这个-ffmpeg.exe"), []string{f}, out, dir, nil)
|
||
if err == nil {
|
||
t.Fatal("拼接应当失败")
|
||
}
|
||
if errors.Is(err, errJobStopped) {
|
||
t.Fatalf("没有停止请求时不该报成已停止:%v", err)
|
||
}
|
||
if !strings.Contains(err.Error(), "拼接") {
|
||
t.Fatalf("错误信息应当说明是拼接失败,实际 %v", err)
|
||
}
|
||
}
|
||
|
||
// 确定性失败不该白白重试三次
|
||
func TestRunThriceSkipsNoRetry(t *testing.T) {
|
||
calls := 0
|
||
err := runThrice(func() error {
|
||
calls++
|
||
return noRetry(errors.New("确定性失败"))
|
||
})
|
||
if calls != 1 {
|
||
t.Fatalf("标记为不重试的错误应当只尝试一次,实际 %d 次", calls)
|
||
}
|
||
if err == nil || !strings.Contains(err.Error(), "确定性失败") {
|
||
t.Fatalf("错误应当原样返回,实际 %v", err)
|
||
}
|
||
if strings.Contains(err.Error(), "运行三次") {
|
||
t.Fatalf("不该被包装成「运行三次都出现错误」:%v", err)
|
||
}
|
||
}
|
||
|
||
// 普通错误仍然照旧重试
|
||
func TestRunThriceStillRetriesNormalError(t *testing.T) {
|
||
calls := 0
|
||
err := runThrice(func() error {
|
||
calls++
|
||
if calls == 2 {
|
||
return nil
|
||
}
|
||
return errors.New("暂时失败")
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("第二次成功时不该报错:%v", err)
|
||
}
|
||
if calls != 2 {
|
||
t.Fatalf("普通错误应当重试,实际只尝试了 %d 次", calls)
|
||
}
|
||
}
|
||
|
||
// 交给 exec 的 ffmpeg 路径必须是绝对路径:
|
||
// Windows 上 exec 会直接拒绝执行「相对当前目录」找到的可执行文件
|
||
func TestGetFFmpegReturnsAbsPath(t *testing.T) {
|
||
p := getFFmpeg()
|
||
if p == "" {
|
||
t.Skip("本机找不到 ffmpeg,跳过")
|
||
}
|
||
if !filepath.IsAbs(p) {
|
||
t.Fatalf("getFFmpeg 应当返回绝对路径,实际 %q", p)
|
||
}
|
||
}
|