Compare commits
7
Commits
6f3c8096b4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dadd3f7cea | ||
|
|
40f4f36aa6 | ||
|
|
2b1705a970 | ||
|
|
7ab80ec429 | ||
|
|
3c17503a21 | ||
|
|
a409c5b4dd | ||
|
|
3e16e78003 |
@@ -75,7 +75,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
||||
"qualities": [] // 开播时读到的码率档位列表,缓存下来让没开播时也能显示对应关系
|
||||
}
|
||||
],
|
||||
"directory": "", // 录制文件保存的文件夹,留空是程序运行时的 -record 文件夹
|
||||
"directory": "", // 录播和弹幕文件的保存根目录,录制时就直接写在这里;留空用运行时的 -record 文件夹
|
||||
"webPort": 51880, // web UI 的端口,为 0 时不启动 web UI
|
||||
|
||||
"autoMerge": false, // 直播结束后自动把弹幕层合并进录播
|
||||
@@ -127,7 +127,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
||||
|
||||
档位表要开播后读直播源列表才知道(写在`config.json`的`qualities`里缓存)。没读到过的行,选项后面不会带档位名,旁边的`档位`按钮可以去直播间接一次(主播没开播时会失败并提示,服务端本身要重试三次、约 5 秒,所以点下去会先给一句`正在读取码率档位…`)。读档位的时机是:录制开始取流时、页面上点`档位`按钮、以及发现主播在直播但还没有档位缓存时自动读一次(同一个主播 10 秒内只读一次)。录制始终是原画质直接保存(`-c copy`),不会重新编码,所以只能在这几个档位里挑。
|
||||
|
||||
录播原片和弹幕文件(`.ass`、`.nick.ass`、`.jsonl`)保存在保存目录下的`recordSubdir`子文件夹里(默认`record`),两者同名配对才能自动合并;合并后的成片保存在`mergeSubdir`子文件夹里(默认`merged`),留空则用默认值,两个名字不能相同。如果设置了`mergeOutputDir`,成片优先移动到那里。
|
||||
录播原片和弹幕文件(`.ass`、`.nick.ass`、`.jsonl`)保存在保存目录下的`recordSubdir`子文件夹里(默认`record`),**录制过程中 ffmpeg 就直接写在这里**,不会再先落到别处中转;两者同名配对才能自动合并。保存目录的优先级是:`directory` > 命令行`-record`指定的文件夹 > 程序所在目录。合并后的成片保存在`mergeSubdir`子文件夹里(默认`merged`),留空则用默认值,两个名字不能相同。如果设置了`mergeOutputDir`,成片优先移动到那里。
|
||||
|
||||
合成**先在录播文件夹里做完,写完了才落到成片文件夹**(同一个盘时是直接改名,几乎瞬时;跨盘会自动拷贝):成片文件夹是网络盘、外接盘或者中途断线时,不会在里面留下一个写到一半、看起来却像正常成片的文件。合成失败或中途强杀程序时,临时文件(`acfunlive_chunk_*`、`*.acfunlive.part`)不会出现在文件管理的列表里,程序下次启动会自动清掉。
|
||||
|
||||
@@ -162,22 +162,23 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
||||
|
||||
web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`),可以在网页里增删主播、切换每个主播的弹幕和录播开关、选择录制的码率档位、修改保存目录和自动合并设置。
|
||||
|
||||
页面按用途分成六个分区,顶部一条导航切换,地址栏会跟着带上`#分区名`,所以刷新、收藏、直接把链接发给别人都能落到同一个分区:
|
||||
页面按用途分成七个分区,顶部一条导航切换,地址栏会跟着带上`#分区名`,所以刷新、收藏、直接把链接发给别人都能落到同一个分区:
|
||||
|
||||
| 分区 | 内容 |
|
||||
| --- | --- |
|
||||
| 监控 | 当前转换任务的进度卡片、添加主播、主播列表(默认打开的分区),以及运行日志 |
|
||||
| 录制 | 保存目录和子文件夹、录播格式、默认码率上限、ffmpeg 路径 |
|
||||
| 合并 | 自动合并开关、成片输出目录和合成码率、手动合并 |
|
||||
| 合并 | 自动合并开关、成片输出目录和合成码率、手动合并(可一次排多个任务,每个任务单独配参数) |
|
||||
| 转码 | 把录播、成片或磁盘上任意位置的视频重新压一遍:每个文件单独配置编码器、码率、帧率、分辨率、音频,并预估产物大小 |
|
||||
| 通知 | 推送哪些事件,以及 QQ 机器人 / MeoW 两个渠道的配置和测试连通 |
|
||||
| 文件 | 磁盘剩余容量、录播和成片的文件列表 |
|
||||
| 设置 | 是否允许外网访问、管理口令的查看与更换 |
|
||||
|
||||
### 转换任务进度
|
||||
|
||||
自动合并和手动合并跑起来之后,`监控`分区顶部会出现一张`转换任务`卡片,页面最顶上还有一条很细的全局进度条,切到哪个分区都能看到。卡片里给的是:
|
||||
自动合并、手动合并和转码跑起来之后,`监控`分区顶部会出现一张`转换任务`卡片,页面最顶上还有一条很细的全局进度条,切到哪个分区都能看到。卡片里给的是:
|
||||
|
||||
- **阶段步骤条**:准备 → 分段拼接 → 获取头像 → 画面渲染 → 拼接与落盘,走过的打勾、当前的高亮(暂停时变红);
|
||||
- **阶段步骤条**:合并是准备 → 分段拼接 → 获取头像 → 画面渲染 → 拼接与落盘,转码是准备 → 转码 → 落盘,走过的打勾、当前的高亮(暂停时变红);一次排了多个任务时标题上还会标`第 n/N 个`;
|
||||
- **总进度条和百分比**:渲染阶段按「已渲染时长 ÷ 视频总时长」算,其余阶段按各自的完成比例折算后加权;
|
||||
- **实时指标**:渲染倍速、帧率、已处理时长 / 总时长、已用时间、已写出多少;
|
||||
- **预计剩余时间**:渲染阶段用「剩余时长 ÷ 当前倍速」,别的阶段按已用时间线性外推;
|
||||
@@ -253,15 +254,47 @@ acfunlive -danmuburn xxx.jsonl -video xxx.flv -ffmpeg D:\tools\ffmpeg.exe
|
||||
|
||||
### 手动合并
|
||||
|
||||
web UI 的`合并`分区里的`手动合并`用来自己挑视频和弹幕出片,适合手动停止录制过的一场、或者手里只有零散文件的情况。
|
||||
web UI 的`合并`分区里的`手动合并`用来自己挑视频和弹幕出片,适合手动停止录制过的一场、或者手里只有零散文件的情况。**可以一次排多个任务,每个任务一套自己的文件和参数。**
|
||||
|
||||
- **视频**:既可以在文本框里一行一个地填,也可以从录播目录的下拉列表里多选(选完自动填进文本框)。填的名字不带路径就按录播目录找,带路径的相对和绝对都能用,所以录播目录以外的文件也合得来。粘贴路径时带上的引号会被自动去掉。
|
||||
- **弹幕**:支持`.jsonl`和`.ass`。填`.ass`时会先还原成 jsonl 再合并(规则和`-danmuburn`一样,优先用同名的 jsonl),还原结果写回录播目录,页面的下拉列表里也会出现。**选好视频会自动找同名弹幕文件**(同目录下依次找`.jsonl`、`.ass`、`.nick.ass`,同目录没有再去录播目录找一次),找到就自动填上并提示一句;找不到、或者弹幕框里已经有内容时就保持原样,自己手选即可。
|
||||
选好视频后会按文件名自动归组:文件名是`2026-09-25 01-00-00 主播名 标题.mp4`这种格式,去掉开头的时间和扩展名后剩下的(主播名 + 标题)相同、而且相邻两段录制时间差在 2 小时以内的算同一场——同一场断流分成的几段归到一个任务里、先拼起来再合,不同场次各排一个任务、依次合出多个成片。归错了(比如同一天连着两场、标题又一样但间隔在 2 小时内)可以点任务行上的`拆成单独任务`把它们拆开。
|
||||
|
||||
每个任务行里能单独改:
|
||||
|
||||
- **视频**:行里列出这一组的文件,可以整组移除或拆成一人一个任务。既可以从录播目录的下拉列表里多选,也可以点`浏览`从别的文件夹挑。填的名字不带路径就按录播目录找,带路径的相对和绝对都能用。
|
||||
- **弹幕**:支持`.jsonl`和`.ass`。填`.ass`时会先还原成 jsonl 再合并(规则和`-danmuburn`一样,优先用同名的 jsonl)。**选好视频会自动找同名弹幕文件**(同目录下依次找`.jsonl`、`.ass`、`.nick.ass`,同目录没有再去录播目录找一次),找到就自动填上;找不到就保持空着,用行里的`…`按钮手选。
|
||||
- **时间偏移**:留空时按文件名里的录制时间自动推算(视频时间 = 弹幕时间 + 偏移),也可以自己填,比如录播比弹幕晚开始 3 秒就填`3`。
|
||||
- **输出文件名**:可以改,重名时自动加`(2)`之类的后缀,不会覆盖已有文件。
|
||||
- **先预览再合并**:`预览`按钮会先把分段顺序、各段时长、总时长、弹幕条数和时间范围、推算出的偏移、以及要用的弹幕文件列出来,确认没问题再点`开始合并`。
|
||||
- **输出文件名**:留空时按弹幕文件名加`_弹幕合并`自动命名,重名会自动加`(2)`之类的后缀;自己填了名字就按填的来(**与已有文件同名时会被覆盖**,注意别填错)。
|
||||
- **本次的合成参数**:码率(Kbps)、输出帧率、弹幕字号(像素)、编码器。**留空就沿用`成片输出`里的全局设置**,填了就只对这一个任务生效——比如某一场要压小一点、另一场保持原码率。
|
||||
- **先预览再合并**:`检查`按钮会把每个任务的分段顺序、各段时长、总时长、弹幕条数和时间范围、推算出的偏移、以及要用的弹幕文件逐段列出来,确认没问题再点`开始合并`。
|
||||
|
||||
一次只允许跑一个手动合并任务,重复提交会被拒绝并提示现在这个成片的名字。合并期间成片会标记`写入中`,和录制一样不能删除;合并失败的原因会显示在页面上,不用去翻日志。多个视频会按文件名里的录制时间自动排序再拼接,所以下拉里选出来的顺序不影响结果,弹幕时间轴会按各段时长重新对齐。
|
||||
一次只跑一个合并任务,排着的按列表顺序来;重复提交会被拒绝并提示现在这个成片的名字。合并期间成片会标记`写入中`,和录制一样不能删除;合并失败的原因会显示在页面上,不用去翻日志。同一组里的多个视频会按文件名里的录制时间自动排序再拼接,所以下拉里选出来的顺序不影响结果,弹幕时间轴会按各段时长重新对齐。
|
||||
|
||||
### 转码
|
||||
|
||||
`转码`分区用来把视频重新压一遍:体积太大想压小、想换成 HEVC、想降分辨率或帧率、想换音轨,都在这里做。源文件不限于本程序录的录播和成片,磁盘上任何位置的文件都能丢进来。**转码不动原文件**,成片另存到输出目录(留空就是成片文件夹)。
|
||||
|
||||
源文件有两个入口,挑哪个都行:
|
||||
|
||||
- **从文件列表挑**:录播和成片文件夹里的文件列在左边,按 Ctrl 点可以多选,选中后点`加入列表`。
|
||||
- **自己填路径**:右边的输入框一行一个路径,可以是从资源管理器复制来的绝对路径,也可以是录播/成片文件夹里的文件名(只写文件名就按这两个文件夹找)。点`浏览…`可以从磁盘上任意位置挑,支持多选、可以跨文件夹跨盘攒,选完只往输入框里追加,不会清掉已经填好的。
|
||||
|
||||
两个入口进来的文件进的是同一个待转码列表,每条互不影响。
|
||||
|
||||
每个文件一行,参数各自独立:
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| 输出文件名 | 默认是`原名_转码.mp4`;这个默认名重名时会自动加`(2)`之类的后缀,自己改成别的名字就按填的来(**与已有文件同名时会被覆盖**) |
|
||||
| 编码器 | `自动`按本机可用性挑(优先 Intel QSV 的 HEVC),也可以指定 HEVC 硬件 / H.264 硬件 / H.264 软件;本机没有的编码器会被挡住 |
|
||||
| 码率控制 | `源片码率的比例`(默认 60%)/ `指定目标码率` / `质量模式`(CRF 或 ICQ,数值越小画质越好)/ `跟随源片码率` |
|
||||
| 编码速度 | veryfast ~ slower,慢一点压得小一点 |
|
||||
| 输出帧率 | 0 表示跟随源片;转码场景下可以按填的值升也可以降 |
|
||||
| 分辨率 | 按高度缩放,只列出比源片低的档,不会把小图放大 |
|
||||
| 音频 | 直接拷贝 / 重编码 128k 或 192k / 去掉音轨 |
|
||||
|
||||
每行下面实时显示**预估产物大小**和相对源片的百分比(体积是服务端按「码率 × 时长」算的,目标码率模式是准数,质量模式只给粗估并标注依据),列表底部还有一整批的合计。调好第一个之后点`把第一个的参数套到全部`可以一次性铺给其它行。
|
||||
|
||||
转码和合并共用同一张进度卡片、同一个任务位,所以一次只跑一个文件,排着的按列表顺序来,也能在`监控`页暂停和停止;停止只丢当前这个,已经在输出目录里落地的成品不受影响。
|
||||
|
||||
### 通知推送
|
||||
|
||||
|
||||
+10
-14
@@ -139,12 +139,14 @@ func speakerText(name string, uid int64, content string) string {
|
||||
}
|
||||
|
||||
// 从 ASS 文件还原弹幕记录,支持原格式(\move)和昵称头像版(\an7\move)
|
||||
func parseAssFile(path string) (danmuMeta, []danmuRecord, error) {
|
||||
// 第二个返回值是没能解析出来的行数,日志交给调用方打:页面的「检查」和真正的合并
|
||||
// 会各解析一遍同一个 .ass,日志只该出现一次
|
||||
func parseAssFile(path string) (danmuMeta, []danmuRecord, int, error) {
|
||||
var meta danmuMeta
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return meta, nil, err
|
||||
return meta, nil, 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
@@ -275,33 +277,27 @@ func parseAssFile(path string) (danmuMeta, []danmuRecord, error) {
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return meta, nil, err
|
||||
return meta, nil, bad, err
|
||||
}
|
||||
|
||||
if meta.PlayResX == 0 || meta.PlayResY == 0 {
|
||||
return meta, nil, errors.New("ASS 文件里没有 PlayResX / PlayResY,可能不是弹幕文件")
|
||||
return meta, nil, bad, errors.New("ASS 文件里没有 PlayResX / PlayResY,可能不是弹幕文件")
|
||||
}
|
||||
if meta.FontSize == 0 {
|
||||
return meta, nil, errors.New("ASS 文件里没找到字幕字号(Style: Danmu)")
|
||||
return meta, nil, bad, errors.New("ASS 文件里没找到字幕字号(Style: Danmu)")
|
||||
}
|
||||
if len(records) == 0 {
|
||||
if bad > 0 {
|
||||
return meta, nil, fmt.Errorf("ASS 里的 %d 行弹幕都没能解析出来", bad)
|
||||
return meta, nil, bad, fmt.Errorf("ASS 里的 %d 行弹幕都没能解析出来", bad)
|
||||
}
|
||||
return meta, nil, errNoDanmu
|
||||
return meta, nil, bad, errNoDanmu
|
||||
}
|
||||
|
||||
// 分块渲染要求记录按时间递增,这里统一排一遍
|
||||
sort.SliceStable(records, func(i, j int) bool { return records[i].Time < records[j].Time })
|
||||
// 文件名前缀就是录制开始的时刻,用它算偏移最稳
|
||||
meta.StartTime = nameStartNs(path)
|
||||
if bad > 0 {
|
||||
lPrintWarnf("%s 里有 %d 行弹幕没能解析出来,已跳过", filepath.Base(path), bad)
|
||||
}
|
||||
lPrintf("已从 %s 还原出 %d 条弹幕(%dx%d,字号 %d,弹幕到 %s)",
|
||||
filepath.Base(path), len(records), meta.PlayResX, meta.PlayResY, meta.FontSize,
|
||||
formatSeconds(records[len(records)-1].Time))
|
||||
return meta, records, nil
|
||||
return meta, records, bad, nil
|
||||
}
|
||||
|
||||
// 按本程序的 jsonl 格式写文件(首行是元数据)
|
||||
|
||||
+19
-2
@@ -85,6 +85,14 @@ func findMovedFile(path string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 收拢到实际位置:找不着时退回原路径,让后面的报错还能指出「本来想找哪儿」
|
||||
func resolveAfterMove(path string) string {
|
||||
if p := findMovedFile(path); p != "" {
|
||||
return p
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// 录播因为断流可能分成多段:单段直接使用,多段先拼成完整视频并把弹幕时间映射到拼接后的时间轴。
|
||||
// 返回合并要用的视频、字幕文件和弹幕相对视频起点的时间偏移、**实际参与合并的源文件**,
|
||||
// 以及清理临时文件的函数。used 是给「合并完删源视频」用的:中途回退成「只合并第一个分段」时,
|
||||
@@ -92,6 +100,10 @@ func findMovedFile(path string) string {
|
||||
func prepareForMerge(ffmpegFile string, segs []recordSegment, jsonlFile string, danmuStartNs int64) (string, string, float64, []string, func(), error) {
|
||||
noop := func() {}
|
||||
|
||||
// 弹幕文件同样会被移动过去。以前只有视频分段走了查找,弹幕还拿着录制时的路径,
|
||||
// 结果就是视频找得到、弹幕找不到,合并以「jsonl 文件不存在」告终
|
||||
jsonlFile = resolveAfterMove(jsonlFile)
|
||||
|
||||
// 录制结束时文件可能已经被移动到保存目录,逐个找实际位置
|
||||
files := make([]string, 0, len(segs))
|
||||
starts := make([]int64, 0, len(segs))
|
||||
@@ -288,6 +300,10 @@ func autoMergeOutFile(jsonlFile string) string {
|
||||
func autoMergeJob(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int64) error {
|
||||
lPrintln("开始自动合并" + longID(uid) + "的录播和弹幕层")
|
||||
|
||||
// 录制结束时弹幕文件可能已被移动到保存目录,先把路径收拢到实际位置:
|
||||
// 中间成片写到哪、事后删哪几个弹幕文件都按这一条路径来,用旧路径会指向错误的地方
|
||||
jsonlFile = resolveAfterMove(jsonlFile)
|
||||
|
||||
// 视频和弹幕是一次录制产生的,文件名去掉扩展名后应该一致(扩展名由 recordFormat 决定)
|
||||
if len(segs) > 0 {
|
||||
videoBase := filepath.Base(strings.TrimSuffix(segs[0].file, filepath.Ext(segs[0].file)))
|
||||
@@ -321,7 +337,7 @@ func autoMergeJob(uid int, segs []recordSegment, jsonlFile string, danmuStartNs
|
||||
inputBytes += fileSizeOrZero(p)
|
||||
}
|
||||
}
|
||||
if !jobStart("自动合并", filepath.Base(outFile), inputBytes, 0) {
|
||||
if !jobStart(jobKindAutoMerge, filepath.Base(outFile), inputBytes, 0) {
|
||||
// 走到这里说明没先拿 mergeSlot,与其把别人的进度冲掉不如直接放弃这一场
|
||||
return errors.New("开始任务失败:还有别的合并任务没结束,本场合并已跳过")
|
||||
}
|
||||
@@ -335,7 +351,8 @@ func autoMergeJob(uid int, segs []recordSegment, jsonlFile string, danmuStartNs
|
||||
|
||||
// 标记成正在写入,避免在 web UI 的文件管理里把还没写完的成片删掉
|
||||
markFileActive(tmpOut, true)
|
||||
burnErr := burnDanmu(jsonl, video, tmpOut, offset)
|
||||
// 自动合并没有单独的参数,全部沿用全局设置
|
||||
burnErr := burnDanmu(jsonl, video, tmpOut, offset, mergeOptions{})
|
||||
markFileActive(tmpOut, false)
|
||||
if burnErr != nil {
|
||||
_ = os.Remove(tmpOut)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 录制结束时文件会被移动到保存目录,视频分段和弹幕文件都要按新位置找。
|
||||
// 以前只有视频分段走了查找,弹幕还拿着录制时的路径:视频找到了、弹幕找不到,
|
||||
// 整场合并以「jsonl 文件不存在」告终(用户看到的就是合成失败 + 一条失败推送)。
|
||||
func TestPrepareForMergeFindsMovedJsonl(t *testing.T) {
|
||||
savedConfig := config
|
||||
defer func() { config = savedConfig }()
|
||||
|
||||
root := t.TempDir()
|
||||
config.Directory = root
|
||||
config.RecordSubdir = "record"
|
||||
if err := os.MkdirAll(recordSaveDir(), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
name := "2026-09-27 22-40-42 甲 标题"
|
||||
// 录制时的位置:文件已经被搬走,这里不该再有
|
||||
oldDir := t.TempDir()
|
||||
oldVideo := filepath.Join(oldDir, name+".mp4")
|
||||
oldJsonl := filepath.Join(oldDir, name+".jsonl")
|
||||
// 实际位置
|
||||
newVideo := filepath.Join(recordSaveDir(), name+".mp4")
|
||||
newJsonl := filepath.Join(recordSaveDir(), name+".jsonl")
|
||||
for _, p := range []string{newVideo, newJsonl} {
|
||||
if err := os.WriteFile(p, []byte("{}\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
segs := []recordSegment{{file: oldVideo, startNs: 0}}
|
||||
video, jsonl, _, _, cleanup, err := prepareForMerge("没有这个-ffmpeg.exe", segs, oldJsonl, 0)
|
||||
defer cleanup()
|
||||
if err != nil {
|
||||
t.Fatalf("单段不该报错:%v", err)
|
||||
}
|
||||
if video != newVideo {
|
||||
t.Errorf("视频应当按保存目录里的实际位置找:\n got %s\n want %s", video, newVideo)
|
||||
}
|
||||
if jsonl != newJsonl {
|
||||
t.Errorf("弹幕文件应当按保存目录里的实际位置找:\n got %s\n want %s", jsonl, newJsonl)
|
||||
}
|
||||
}
|
||||
|
||||
// 文件确实不在(既没在录制时的位置、也不在保存目录)时,要退回原路径而不是空串:
|
||||
// 否则报错会变成「文件不存在:」,看不出到底缺的是哪个
|
||||
func TestResolveAfterMoveKeepsOriginalWhenMissing(t *testing.T) {
|
||||
savedConfig := config
|
||||
defer func() { config = savedConfig }()
|
||||
|
||||
root := t.TempDir()
|
||||
config.Directory = root
|
||||
config.RecordSubdir = "record"
|
||||
|
||||
gone := filepath.Join(t.TempDir(), "这个目录是对的但文件不在.mp4")
|
||||
if got := resolveAfterMove(gone); got != gone {
|
||||
t.Errorf("找不到时应当退回原路径:got %q want %q", got, gone)
|
||||
}
|
||||
|
||||
real := filepath.Join(root, "record", "在保存目录里.mp4")
|
||||
if err := os.MkdirAll(filepath.Dir(real), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(real, []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := resolveAfterMove(real); got != real {
|
||||
t.Errorf("文件就在原路径时不该改:got %q want %q", got, real)
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,7 @@ var configMu sync.Mutex
|
||||
// 设置数据
|
||||
type configData struct {
|
||||
UIDs uidList `json:"uids"` // 要录制的主播 uid 列表和各自的开关
|
||||
Directory string `json:"directory"` // 弹幕文件录制结束后会被移动到该文件夹
|
||||
Directory string `json:"directory"` // 录播和弹幕文件保存的根目录,留空用 -record 指定的文件夹
|
||||
WebPort int `json:"webPort"` // web UI 的本地端口,为 0 时不启动 web UI
|
||||
|
||||
AutoMerge bool `json:"autoMerge"` // 直播结束后自动把弹幕层合并进录播
|
||||
@@ -190,25 +190,29 @@ func saveRootDir() string {
|
||||
if config.Directory != "" {
|
||||
return absOrSelf(config.Directory)
|
||||
}
|
||||
if recordDir == nil {
|
||||
return ""
|
||||
if recordDir != nil && *recordDir != "" {
|
||||
return absOrSelf(*recordDir)
|
||||
}
|
||||
return absOrSelf(*recordDir)
|
||||
// 命令行参数还没解析时(比如 initialize 之前被调用)退回程序所在目录,
|
||||
// 再不济退回当前工作目录:这里返回空串的话,后面拼出来的就是个相对路径,
|
||||
// ffmpeg 会按自己的当前目录去写,录制文件就不知道落到哪去了
|
||||
if exeDir != "" {
|
||||
return absOrSelf(exeDir)
|
||||
}
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
return wd
|
||||
}
|
||||
return "."
|
||||
}
|
||||
|
||||
// 录播和弹幕文件保存的文件夹
|
||||
// 录播和弹幕文件保存的文件夹。
|
||||
// 录制过程中 ffmpeg 就直接写在这里,不会再中途挪窝:以前单独有一个「录制中的目录」,
|
||||
// 它只认 -record 参数不看 directory 设置,配了保存目录的人会发现文件先落在程序目录下,
|
||||
// 录完才搬一次(跨盘就是整份文件再复制一遍),提前中断还会留在那儿。
|
||||
func recordSaveDir() string {
|
||||
return filepath.Join(saveRootDir(), config.RecordSubdir)
|
||||
}
|
||||
|
||||
// 录制过程中文件直接写入的文件夹
|
||||
func recordingDir() string {
|
||||
if recordDir == nil {
|
||||
return config.RecordSubdir
|
||||
}
|
||||
return filepath.Join(absOrSelf(*recordDir), config.RecordSubdir)
|
||||
}
|
||||
|
||||
// 合并成片保存的文件夹
|
||||
func mergeSaveDir() string {
|
||||
if config.MergeOutputDir != "" {
|
||||
@@ -400,6 +404,20 @@ func currentAuthToken() string {
|
||||
return config.AuthToken
|
||||
}
|
||||
|
||||
// 检查配置里的文件夹字段,返回错误信息,空字符串表示通过。
|
||||
// 启动读配置、web UI 改设置、转码排队都用这一份 —— 以前各自实现,
|
||||
// 启动那份直接 os.Stat + checkErr,目录不存在时会 panic 崩掉整个进程。
|
||||
func checkDirField(path, name string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || !info.IsDir() {
|
||||
return name + " 必须是存在的文件夹:" + path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 检查 config.json 里的配置
|
||||
func checkConfig() {
|
||||
for _, u := range config.UIDs {
|
||||
@@ -412,21 +430,15 @@ func checkConfig() {
|
||||
lPrintErr(configFile + "里的 webPort 必须为 0(不启动 web UI)或者大于 1023 且少于 65536")
|
||||
os.Exit(1)
|
||||
}
|
||||
if config.Directory != "" {
|
||||
info, err := os.Stat(config.Directory)
|
||||
checkErr(err)
|
||||
if !info.IsDir() {
|
||||
lPrintErrf("%s里的directory必须是存在的文件夹:%s", configFile, config.Directory)
|
||||
os.Exit(1)
|
||||
}
|
||||
// 目录不在(盘没插、路径拼错)要给人话提示然后退出,不能直接 panic:
|
||||
// 崩溃时文件系统风险未知,而且服务被托管时会被反复拉起来
|
||||
if msg := checkDirField(config.Directory, configFile+"里的directory"); msg != "" {
|
||||
lPrintErr(msg)
|
||||
os.Exit(1)
|
||||
}
|
||||
if config.MergeOutputDir != "" {
|
||||
info, err := os.Stat(config.MergeOutputDir)
|
||||
checkErr(err)
|
||||
if !info.IsDir() {
|
||||
lPrintErrf("%s里的mergeOutputDir必须是存在的文件夹:%s", configFile, config.MergeOutputDir)
|
||||
os.Exit(1)
|
||||
}
|
||||
if msg := checkDirField(config.MergeOutputDir, configFile+"里的mergeOutputDir"); msg != "" {
|
||||
lPrintErr(msg)
|
||||
os.Exit(1)
|
||||
}
|
||||
if config.MergeBitrate < 0 {
|
||||
lPrintErrf("%s里的mergeBitrate不能小于 0", configFile)
|
||||
@@ -480,7 +492,7 @@ func checkConfig() {
|
||||
config.FFmpegPath = ""
|
||||
}
|
||||
// 确保保存文件夹存在
|
||||
for _, d := range []string{recordingDir(), recordSaveDir(), mergeSaveDir()} {
|
||||
for _, d := range []string{recordSaveDir(), mergeSaveDir()} {
|
||||
if err := os.MkdirAll(d, 0755); err != nil {
|
||||
lPrintErrf("创建文件夹 %s 失败:%v", d, err)
|
||||
os.Exit(1)
|
||||
@@ -1119,7 +1131,9 @@ func (s *streamer) moveDanmuFiles(assFile string) {
|
||||
s.moveFile(jsonlFile)
|
||||
}
|
||||
|
||||
// 移动文件
|
||||
// 把送出文件到保存目录下,路径一致时什么也不做。
|
||||
// 录制的产出本来就写在 recordSaveDir() 里(见 recordSaveDir 的注释),
|
||||
// 这里是条保险:路径由别处给进来、或者运行中被改过保存目录时,仍以当前配置为准收拢一次。
|
||||
func (s *streamer) moveFile(oldFile string) {
|
||||
if oldFile == "" {
|
||||
return
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -145,3 +146,48 @@ func TestReloadConfigKeepsMergeTempFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存目录填错(盘没插、路径拼错)时启动必须给一句人话再退出,
|
||||
// 不能 panic 崩掉整个进程 —— 服务被托管时崩了还会被反复拉起来
|
||||
func TestCheckDirFieldReportsMissingDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if msg := checkDirField("", "directory"); msg != "" {
|
||||
t.Errorf("留空表示不指定,不该报错:%s", msg)
|
||||
}
|
||||
if msg := checkDirField(root, "directory"); msg != "" {
|
||||
t.Errorf("存在的文件夹不该报错:%s", msg)
|
||||
}
|
||||
if msg := checkDirField(filepath.Join(root, "没有这个目录"), "directory"); !strings.Contains(msg, "必须是存在的文件夹") {
|
||||
t.Errorf("目录不存在时要给出提示,实际返回 %q", msg)
|
||||
}
|
||||
file := filepath.Join(root, "afile")
|
||||
if err := os.WriteFile(file, []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg := checkDirField(file, "directory"); msg == "" {
|
||||
t.Error("指向文件而不是文件夹时应当报错")
|
||||
}
|
||||
}
|
||||
|
||||
// checkConfig 里曾经直接 os.Stat + checkErr:目录不在时会 panic 崩掉进程。
|
||||
// 这条路径没法在测试里真跑一遍(失败时是 os.Exit(1),会把整个测试进程带走),
|
||||
// 所以退一步把「只能给提示再退出」钉在源码上。
|
||||
func TestCheckConfigNeverPanicsOnMissingDir(t *testing.T) {
|
||||
src, err := os.ReadFile("config.go")
|
||||
if err != nil {
|
||||
t.Skip("读不到 config.go:" + err.Error())
|
||||
}
|
||||
text := string(src)
|
||||
start := strings.Index(text, "func checkConfig()")
|
||||
if start < 0 {
|
||||
t.Fatal("config.go 里找不到 checkConfig")
|
||||
}
|
||||
rest := text[start:]
|
||||
end := strings.Index(rest, "\n}\n")
|
||||
if end < 0 {
|
||||
t.Fatal("checkConfig 的函数体没有正常收尾")
|
||||
}
|
||||
if strings.Contains(rest[:end], "checkErr(") {
|
||||
t.Error("checkConfig 里不能用 checkErr:出错会变成 panic 崩进程,改成 lPrintErr + os.Exit(1)")
|
||||
}
|
||||
}
|
||||
|
||||
+157
-86
@@ -20,7 +20,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -147,6 +146,70 @@ type burnContext struct {
|
||||
// 透明头像层(无源片模式)默认的帧率
|
||||
const defaultLayerFps = 30
|
||||
|
||||
// 一次合成的参数。手动合并里每个任务可以自带一套,不带的项回落到全局设置。
|
||||
//
|
||||
// 码率、帧率、字号都用指针:0 本身是有明确含义的(自动码率 / 跟随源片 / 自动字号),
|
||||
// 用普通 int 分不清「没填」和「填了 0」,手动合并就会把用户在全局设置里填的值顶掉
|
||||
type mergeOptions struct {
|
||||
Bitrate *int `json:"bitrate,omitempty"` // 合成码率(Kbps),0 = 按「源片码率 + 弹幕余量」自动
|
||||
Fps *int `json:"fps,omitempty"` // 输出帧率,0 = 跟随源片
|
||||
FontSize *int `json:"fontSize,omitempty"` // 弹幕字号(像素),0 = 按画面分辨率自动
|
||||
Encoder string `json:"encoder,omitempty"` // 指定 ffmpeg 编码器,空或 auto = 自动
|
||||
}
|
||||
|
||||
// 取本次合成用的码率 / 帧率 / 字号,没指定就沿用全局设置
|
||||
func (o mergeOptions) bitrate() int {
|
||||
if o.Bitrate != nil {
|
||||
return *o.Bitrate
|
||||
}
|
||||
return config.MergeBitrate
|
||||
}
|
||||
|
||||
func (o mergeOptions) fps() int {
|
||||
if o.Fps != nil {
|
||||
return *o.Fps
|
||||
}
|
||||
return config.MergeFps
|
||||
}
|
||||
|
||||
func (o mergeOptions) fontSize() int {
|
||||
if o.FontSize != nil {
|
||||
return *o.FontSize
|
||||
}
|
||||
return config.DanmuFontSize
|
||||
}
|
||||
|
||||
// 指定的编码器(空表示让 ffmpeg 那边按可用性自动挑)
|
||||
func (o mergeOptions) encoder() string {
|
||||
if o.Encoder == "" || o.Encoder == "auto" {
|
||||
return ""
|
||||
}
|
||||
return o.Encoder
|
||||
}
|
||||
|
||||
// 检查一次合成的参数,返回给用户的错误说明(空字符串表示通过)
|
||||
func (o mergeOptions) validate(ffmpegFile string) string {
|
||||
if o.Bitrate != nil && (*o.Bitrate < 0 || *o.Bitrate > maxBitrateKbps) {
|
||||
return fmt.Sprintf("合成码率要在 0(自动)到 %d(Kbps)之间", maxBitrateKbps)
|
||||
}
|
||||
if o.Fps != nil && (*o.Fps < 0 || *o.Fps > maxMergeFps) {
|
||||
return fmt.Sprintf("输出帧率只能在 0(跟随源片)到 %d 之间", maxMergeFps)
|
||||
}
|
||||
if o.FontSize != nil && *o.FontSize < 0 {
|
||||
return "弹幕字号不能小于 0(0 表示按画面分辨率自动)"
|
||||
}
|
||||
if enc := o.encoder(); enc != "" {
|
||||
for _, n := range availableEncoders(ffmpegFile) {
|
||||
if n == enc {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("本机没有可用的编码器 %s,可用的是:%s",
|
||||
enc, strings.Join(availableEncoders(ffmpegFile), "、"))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 设置里的输出帧率 vs 源片帧率:只在目标**更高**时才加 fps 滤镜 —— 降帧只会更卡。
|
||||
// 源片帧率探测不到(0)时也不动,免得把高帧率源压到某个值
|
||||
func outputFps(target int, source float64) int {
|
||||
@@ -275,7 +338,7 @@ func runDanmuburn(danmuFile, videoFile, outFile string, offset float64) {
|
||||
}
|
||||
// 还原结果写不进原目录时会落在系统临时目录里,收工前删掉。
|
||||
// 这里到处都是 os.Exit,defer 不会被执行,所以显式调用
|
||||
if err := burnDanmu(src.jsonlPath, videoFile, outFile, offset); err != nil {
|
||||
if err := burnDanmu(src.jsonlPath, videoFile, outFile, offset, mergeOptions{}); err != nil {
|
||||
dropTempDanmu(src)
|
||||
if err == errNoDanmu {
|
||||
lPrintWarn(err.Error())
|
||||
@@ -287,9 +350,68 @@ func runDanmuburn(danmuFile, videoFile, outFile string, offset float64) {
|
||||
dropTempDanmu(src)
|
||||
}
|
||||
|
||||
// 按偏移算出视频时间,落在视频时长之外的丢掉;返回留下的弹幕和被丢掉的条数
|
||||
func clipDanmuToVideo(records []danmuRecord, offset, videoDuration float64) ([]danmuRecord, int) {
|
||||
kept := make([]danmuRecord, 0, len(records))
|
||||
for _, r := range records {
|
||||
if r.Time+offset < videoDuration {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
}
|
||||
return kept, len(records) - len(kept)
|
||||
}
|
||||
|
||||
// 把弹幕对齐到视频的时间轴。
|
||||
// 正常情况就是「超出视频时长的部分丢掉」;但 upstream 录出来的 .ass 时间不一定相对录制起点
|
||||
// (程序先开着、直播中途才开始录的场次,弹幕时间基准会比视频晚一大截),
|
||||
// 那种场次会被整体判成「超出时长」然后一条都合不出来,这里按「第一条弹幕对齐视频起点」平移救回来。
|
||||
// 只在「一条都用不上」且「弹幕跨度装得进视频」时才平移,本来就能合成的场次完全不受影响。
|
||||
// 返回调整后的弹幕和偏移(后续渲染都用这个偏移)。
|
||||
func fitDanmuToVideo(records []danmuRecord, offset, videoDuration float64) ([]danmuRecord, float64) {
|
||||
kept, dropped := clipDanmuToVideo(records, offset, videoDuration)
|
||||
if dropped == 0 {
|
||||
return records, offset
|
||||
}
|
||||
if len(kept) > 0 {
|
||||
// 只是一部分超出(弹幕确实录得比视频久),保持原行为:丢掉超出的那部分
|
||||
lPrintWarnf("有 %d 条弹幕超出了录播视频的时长(%.1f 秒),已跳过", dropped, videoDuration)
|
||||
return kept, offset
|
||||
}
|
||||
|
||||
first, last := records[0].Time, records[0].Time
|
||||
for _, r := range records[1:] {
|
||||
if r.Time < first {
|
||||
first = r.Time
|
||||
}
|
||||
if r.Time > last {
|
||||
last = r.Time
|
||||
}
|
||||
}
|
||||
late := first + offset // 最早的那条比视频起点晚多少秒(走到这里必然 ≥ 视频时长)
|
||||
if last-first >= videoDuration {
|
||||
// 弹幕跨度本身就装不进视频:平移也救不回来
|
||||
lPrintWarnf("有 %d 条弹幕超出了录播视频的时长(%.1f 秒),已跳过", dropped, videoDuration)
|
||||
return nil, offset
|
||||
}
|
||||
|
||||
newOffset := -first
|
||||
lPrintWarnf("弹幕时间基准和视频对不上:%d 条弹幕全部落在视频时长(%.1f 秒)之外,最早那条在 %.1f 秒;"+
|
||||
"已按「第一条弹幕对齐视频起点」整体前移 %.1f 秒(偏移 %.1f → %.1f)",
|
||||
len(records), videoDuration, late, late, offset, newOffset)
|
||||
|
||||
// 跨度装得进视频,所以平移之后不会再丢弹幕
|
||||
kept, _ = clipDanmuToVideo(records, newOffset, videoDuration)
|
||||
start, end := kept[0].Time+newOffset, kept[len(kept)-1].Time+newOffset
|
||||
lPrintf("对齐后弹幕覆盖 %.1f ~ %.1f 秒,视频总长 %.1f 秒(尾部空 %.1f 秒);"+
|
||||
"如果弹幕整体偏早或偏晚,在「偏移」里微调后重新合并",
|
||||
start, end, videoDuration, videoDuration-end)
|
||||
return kept, newOffset
|
||||
}
|
||||
|
||||
// 合成一个 jsonl(和可选的视频)为带头像的弹幕层
|
||||
// videoFile 为空时输出透明头像层(ProRes 4444),否则把弹幕层压进视频
|
||||
func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
||||
// videoFile 为空时输出透明头像层(ProRes 4444),否则把弹幕层压进视频。
|
||||
// opt 是这一次的合成参数,不填的项用全局设置(见 mergeOptions)
|
||||
func burnDanmu(jsonlFile, videoFile, outFile string, offset float64, opt mergeOptions) error {
|
||||
jsonlFile, err := filepath.Abs(jsonlFile)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -362,21 +484,19 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
||||
return errors.New("找不到 ffmpeg,请先安装并加入 PATH,或把 ffmpeg 放在本程序所在文件夹,或在设置里填写 ffmpeg 路径")
|
||||
}
|
||||
|
||||
// 弹幕可能录得比视频久(视频中途中断过),超出视频时长的部分没法渲染,直接丢掉
|
||||
// 时长、分辨率、码率、帧率一次探测拿全(原来为了拿时长和视频流各起了一次 ffmpeg)
|
||||
var vinfo mediaInfo
|
||||
if burnMode {
|
||||
vinfo = probeMedia(ffmpegFile, videoFile)
|
||||
}
|
||||
|
||||
// 弹幕可能录得比视频久(视频中途中断过),超出视频时长的部分没法渲染,直接丢掉;
|
||||
// 时间基准整体偏移的 .ass(见 fitDanmuToVideo)在这一步救回来
|
||||
videoDuration := 0.0
|
||||
if burnMode {
|
||||
videoDuration = probeDuration(ffmpegFile, videoFile)
|
||||
videoDuration = vinfo.duration
|
||||
if videoDuration > 0 {
|
||||
kept := records[:0]
|
||||
for _, r := range records {
|
||||
if r.Time+offset < videoDuration {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
}
|
||||
if dropped := len(records) - len(kept); dropped > 0 {
|
||||
lPrintWarnf("有 %d 条弹幕超出了录播视频的时长(%.1f 秒),已跳过", dropped, videoDuration)
|
||||
}
|
||||
records = kept
|
||||
records, offset = fitDanmuToVideo(records, offset, videoDuration)
|
||||
if len(records) == 0 {
|
||||
return errNoDanmu
|
||||
}
|
||||
@@ -384,17 +504,17 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
||||
}
|
||||
|
||||
// 弹幕文件的坐标系和视频对不上时,统一到视频坐标系(见 unifyDanmuSpace);
|
||||
// 顺便拿到源片码率(用于「合成码率填 0」时的自动码率)和帧率(用于输出帧率)
|
||||
// 顺便拿到源片码率(用于「合成码率填 0」时的自动码率)和帧率(用于输出帧率)。
|
||||
// 码率沿用「视频流自己的,没有就用容器总码率」的老口径(见 streamOrContainerKbps)
|
||||
sourceKbps := 0
|
||||
sourceFps := 0.0
|
||||
if burnMode {
|
||||
info := probeVideoStream(ffmpegFile, videoFile)
|
||||
sourceKbps, sourceFps = info.kbps, info.fps
|
||||
unifyDanmuSpace(&meta, info.width, info.height)
|
||||
sourceKbps, sourceFps = streamOrContainerKbps(vinfo), vinfo.fps
|
||||
unifyDanmuSpace(&meta, vinfo.width, vinfo.height)
|
||||
lPrintf("视频编码器优先顺序:%s", strings.Join(availableEncoders(ffmpegFile), " → "))
|
||||
}
|
||||
// 字号在坐标系统一之后再定,此时 meta.PlayRes 就是成片画布
|
||||
applyFontSize(&meta, config.DanmuFontSize)
|
||||
applyFontSize(&meta, opt.fontSize())
|
||||
// 弹道按最终字号统一重排,避免还原弹幕或改字号后同弹道追尾
|
||||
relayoutLanes(records, meta)
|
||||
|
||||
@@ -426,7 +546,7 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
||||
}
|
||||
|
||||
// 合成码率:填了值就用它;填 0(默认)按「源片码率 + 弹幕余量」算,探测不到才退回质量模式
|
||||
bitrate := config.MergeBitrate + burnBitrate
|
||||
bitrate := opt.bitrate() + burnBitrate
|
||||
if bitrate == 0 && burnMode {
|
||||
if auto := autoMergeBitrate(sourceKbps); auto > 0 {
|
||||
lPrintf("合成码率填的 0,按源片 %d kbps + 弹幕余量 %d kbps 取 %d kbps", sourceKbps, danmuBitrateMargin, auto)
|
||||
@@ -436,11 +556,12 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 输出帧率:设置填 0 = 跟随源片(不动)。低帧率录播(15/24fps)里弹幕是一格一格跳的,
|
||||
// 输出帧率:填 0 = 跟随源片(不动)。低帧率录播(15/24fps)里弹幕是一格一格跳的,
|
||||
// 提到更高帧率之后弹幕层按新帧率重绘,字和头像都顺了;源片本来就够高就不动
|
||||
outFps := outputFps(config.MergeFps, sourceFps)
|
||||
if burnMode && config.MergeFps > 0 && outFps == 0 {
|
||||
lPrintf("输出帧率:源片 %.2f fps 已经不低于设置的 %d fps,不做处理", sourceFps, config.MergeFps)
|
||||
wantFps := opt.fps()
|
||||
outFps := outputFps(wantFps, sourceFps)
|
||||
if burnMode && wantFps > 0 && outFps == 0 {
|
||||
lPrintf("输出帧率:源片 %.2f fps 已经不低于设置的 %d fps,不做处理", sourceFps, wantFps)
|
||||
}
|
||||
if outFps > 0 {
|
||||
lPrintf("输出帧率:源片 %.2f fps,按设置提到 %d fps(弹幕层会按新帧率重绘)", sourceFps, outFps)
|
||||
@@ -465,7 +586,8 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
||||
bitrate: bitrate,
|
||||
dir: workDir,
|
||||
outFps: outFps,
|
||||
layerFps: layerFps(config.MergeFps),
|
||||
layerFps: layerFps(wantFps),
|
||||
encoder: opt.encoder(),
|
||||
}
|
||||
|
||||
chunks := splitDanmuChunks(records, offset, videoDuration)
|
||||
@@ -674,51 +796,14 @@ type videoStreamInfo struct {
|
||||
fps float64 // 帧率
|
||||
}
|
||||
|
||||
// 探测视频流的分辨率、码率和帧率
|
||||
// 探测视频流的分辨率、码率和帧率。
|
||||
// 实际解析在 transcode.go 的 parseMediaInfo 里(一次拿全时长/画面/音轨),这里只挑视频那几个字段
|
||||
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()
|
||||
|
||||
var info videoStreamInfo
|
||||
for _, line := range strings.Split(buf.String(), "\n") {
|
||||
// 容器的总码率(含音频)先记下来,视频流自己没写码率时用它
|
||||
if strings.Contains(line, "Duration:") {
|
||||
if m := containerBitrateRe.FindStringSubmatch(line); len(m) == 2 {
|
||||
info.kbps, _ = strconv.Atoi(m[1])
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 只看视频流,跳过封面图(mjpeg 之类的 attached pic)那几行
|
||||
if !strings.Contains(line, "Video:") || strings.Contains(line, "attached pic") {
|
||||
continue
|
||||
}
|
||||
if m := videoSizeRe.FindStringSubmatch(line); len(m) == 3 {
|
||||
info.width, _ = strconv.Atoi(m[1])
|
||||
info.height, _ = strconv.Atoi(m[2])
|
||||
}
|
||||
if m := videoBitrateRe.FindStringSubmatch(line); len(m) == 2 {
|
||||
info.kbps, _ = strconv.Atoi(m[1])
|
||||
}
|
||||
if m := videoFpsRe.FindStringSubmatch(line); len(m) == 2 {
|
||||
info.fps, _ = strconv.ParseFloat(m[1], 64)
|
||||
}
|
||||
}
|
||||
return info
|
||||
m := probeMedia(ffmpegFile, path)
|
||||
// 口径跟以前一致:视频流自己没写码率就退回容器总码率,别改成扣掉音频的版本
|
||||
return videoStreamInfo{width: m.width, height: m.height, kbps: streamOrContainerKbps(m), fps: m.fps}
|
||||
}
|
||||
|
||||
var (
|
||||
// 逗号后紧跟的「数字x数字」就是分辨率,前面的编码器名里也有 0x… 这种十六进制,靠逗号区分
|
||||
videoSizeRe = regexp.MustCompile(`, (\d{2,5})x(\d{2,5})[, \[]`)
|
||||
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(默认)时的算法:源片码率 + 弹幕余量。
|
||||
// 烧进去的文字和头像都是高频细节,跟源片同样码率会糊,所以留一点余量;
|
||||
// 探测不到源片码率时返回 0,让调用方退回质量模式。
|
||||
@@ -740,26 +825,12 @@ const (
|
||||
minMergeBitrate = 800
|
||||
)
|
||||
|
||||
// 用 ffmpeg 探测媒体文件的时长(秒),探测不到返回 0
|
||||
// 用 ffmpeg 探测媒体文件的时长(秒),探测不到返回 0。
|
||||
// 解析在 transcode.go 的 parseMediaInfo 里(一次拿全时长/画面/音轨),这里保留薄封装给老调用点
|
||||
func probeDuration(ffmpegFile, path string) float64 {
|
||||
cmd := exec.Command(ffmpegFile, "-hide_banner", "-i", path)
|
||||
var buf bytes.Buffer
|
||||
cmd.Stderr = &buf
|
||||
// 没指定输出文件时 ffmpeg 会返回非 0,但时长信息已经打印出来了
|
||||
_ = cmd.Run()
|
||||
|
||||
m := durationRe.FindStringSubmatch(buf.String())
|
||||
if len(m) != 4 {
|
||||
return 0
|
||||
}
|
||||
h, _ := strconv.Atoi(m[1])
|
||||
mi, _ := strconv.Atoi(m[2])
|
||||
s, _ := strconv.ParseFloat(m[3], 64)
|
||||
return float64(h)*3600 + float64(mi)*60 + s
|
||||
return probeMedia(ffmpegFile, path).duration
|
||||
}
|
||||
|
||||
var durationRe = regexp.MustCompile(`Duration: (\d+):(\d+):(\d+\.\d+)`)
|
||||
|
||||
// 弹幕不多时直接一次渲染完,同样从 jsonl 生成字幕,不依赖录制时留下的 .nick.ass
|
||||
func (c *burnContext) burnSingle(records []danmuRecord) error {
|
||||
chunk := danmuChunk{start: 0, end: math.Inf(1), records: records}
|
||||
|
||||
+1
-1
@@ -24,5 +24,5 @@ func transFilename(filename string) string {
|
||||
}
|
||||
filename = filename[:cut]
|
||||
}
|
||||
return filepath.Join(recordingDir(), filename)
|
||||
return filepath.Join(recordSaveDir(), filename)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 录制过程中 ffmpeg 是按 transFilename 给的路径写文件的。
|
||||
// 以前这条路径走的是 -record 参数而不是 directory 设置,配了保存目录的人会看到
|
||||
// 文件先落在程序目录里、录完才搬一次;这里把「必须等于最终保存目录」钉死。
|
||||
func TestTransFilenameSavesIntoConfiguredDir(t *testing.T) {
|
||||
savedConfig, savedRecordDir := config, recordDir
|
||||
defer func() { config, recordDir = savedConfig, savedRecordDir }()
|
||||
|
||||
root, dashDir := t.TempDir(), t.TempDir()
|
||||
recordDir = &dashDir
|
||||
config.RecordSubdir = "record"
|
||||
|
||||
// 配了 directory:不管 -record 参数是什么,都得以它为准
|
||||
config.Directory = root
|
||||
got := filepath.Clean(transFilename("2026-09-27 22-40-42 甲 标题.mp4"))
|
||||
want := filepath.Join(root, "record", "2026-09-27 22-40-42 甲 标题.mp4")
|
||||
if got != filepath.Clean(want) {
|
||||
t.Errorf("配了 directory 后录制路径不对:\n got %s\n want %s", got, want)
|
||||
}
|
||||
if !strings.HasPrefix(got, filepath.Clean(root)) {
|
||||
t.Errorf("录制路径落在了保存目录之外:%s", got)
|
||||
}
|
||||
|
||||
// 没配 directory:回落到 -record 指定的文件夹
|
||||
config.Directory = ""
|
||||
got = filepath.Clean(transFilename("x.mp4"))
|
||||
if filepath.Dir(got) != filepath.Clean(filepath.Join(dashDir, "record")) {
|
||||
t.Errorf("directory 为空时应回落到 -record 目录:%s", got)
|
||||
}
|
||||
|
||||
// 连 -record 都还没解析时也不能拼出相对路径,否则 ffmpeg 会写到进程的工作目录去
|
||||
blank := ""
|
||||
recordDir = &blank
|
||||
if root := saveRootDir(); !filepath.IsAbs(root) {
|
||||
t.Errorf("传入的参数都没解析时 saveRootDir 仍应是绝对路径,实际 %q", root)
|
||||
}
|
||||
if got := filepath.Clean(transFilename("x.mp4")); !filepath.IsAbs(got) {
|
||||
t.Errorf("录制路径不能是相对路径:%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存目录和录制目录现在是一个,moveFile 再挪一遍等于白复制一份(跨盘时尤其慢)
|
||||
func TestMoveFileKeepsFileInSaveDir(t *testing.T) {
|
||||
savedConfig, savedRecordDir := config, recordDir
|
||||
defer func() { config, recordDir = savedConfig, savedRecordDir }()
|
||||
|
||||
root := t.TempDir()
|
||||
recordDir = &root
|
||||
config.Directory = root
|
||||
config.RecordSubdir = "record"
|
||||
|
||||
if err := os.MkdirAll(recordSaveDir(), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := filepath.Join(recordSaveDir(), "2026-09-27 22-40-42 甲 标题.mp4")
|
||||
if err := os.WriteFile(p, []byte("fake"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := &streamer{UID: 1}
|
||||
s.moveFile(p)
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Errorf("文件已经在保存目录里了,moveFile 不该动它:%v", err)
|
||||
}
|
||||
|
||||
// 别处给进来的路径(比如手填的绝对路径)仍要收拢到保存目录
|
||||
outside := filepath.Join(t.TempDir(), "outside.mp4")
|
||||
if err := os.WriteFile(outside, []byte("fake"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.moveFile(outside)
|
||||
if _, err := os.Stat(outside); !os.IsNotExist(err) {
|
||||
t.Errorf("保存目录之外的文件应当被移动,原位置却还在")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(recordSaveDir(), "outside.mp4")); err != nil {
|
||||
t.Errorf("文件没有出现在保存目录里:%v", err)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -16,7 +16,7 @@ func transFilename(filename string) string {
|
||||
// 转换文件名不允许的特殊字符
|
||||
re := regexp.MustCompile(`[<>:"/\\|?*\r\n]`)
|
||||
filename = re.ReplaceAllString(filename, "-")
|
||||
dir := recordingDir()
|
||||
dir := recordSaveDir()
|
||||
outFilename := filepath.Join(dir, filename)
|
||||
if utf8.RuneCountInString(outFilename) <= maxPathRunes {
|
||||
return outFilename
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/orzogc/acfunlive
|
||||
|
||||
go 1.26.0
|
||||
go 1.27.3
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.13.0
|
||||
|
||||
+189
-38
@@ -27,13 +27,39 @@ var mergeVideoExts = map[string]struct{}{
|
||||
var mergeDanmuExts = map[string]struct{}{".jsonl": {}, ".ass": {}}
|
||||
|
||||
// 手动合并的请求体
|
||||
//
|
||||
// 两种写法:只填 videos/danmu 是单个任务(老样子);填 jobs 是一次排多个任务,
|
||||
// 每个任务自带视频、弹幕和合成参数,服务端按顺序串行合并
|
||||
type mergeRequest struct {
|
||||
Videos []string `json:"videos"` // 视频文件,可多个(断流分段):文件名或绝对/相对路径
|
||||
Danmu string `json:"danmu"` // 弹幕文件:.jsonl 直接用,.ass / .nick.ass 先还原成 jsonl
|
||||
Offset *float64 `json:"offset"` // 手动指定的偏移(秒),不填则按文件名里的录制时间推算
|
||||
OutName string `json:"outName"` // 输出文件名,留空用默认名且自动避开重名
|
||||
|
||||
Options mergeOptions `json:"options"` // 这一次合成的参数,不填的项用全局设置
|
||||
Jobs []mergeJobReq `json:"jobs"` // 批量:每个任务一套自己的文件和参数
|
||||
}
|
||||
|
||||
// 批量里的一个合并任务
|
||||
type mergeJobReq struct {
|
||||
Videos []string `json:"videos"` // 这一组的视频,多个就是同一场的断流分段,会先拼起来
|
||||
Danmu string `json:"danmu"` // 这一组的弹幕文件
|
||||
Offset *float64 `json:"offset"` // 这一组的偏移
|
||||
OutName string `json:"outName"` // 这一组的输出文件名
|
||||
Options mergeOptions `json:"options"` // 这一组的合成参数
|
||||
}
|
||||
|
||||
// 一次最多排多少个合并任务。再多就得等太久,队列状态也会一直占着
|
||||
const maxMergeJobs = 50
|
||||
|
||||
// 批量任务里每个任务的结果,页面按它显示「第几个、成功还是失败」
|
||||
type mergeJobResult struct {
|
||||
OutName string `json:"outName"`
|
||||
Err string `json:"err"` // 空表示成功
|
||||
}
|
||||
|
||||
func (r mergeJobResult) ok() bool { return r.Err == "" }
|
||||
|
||||
// 检查结果里每个视频的信息
|
||||
type mergeVideoInfo struct {
|
||||
Name string `json:"name"`
|
||||
@@ -65,6 +91,7 @@ type mergePlan struct {
|
||||
out string // 成片的绝对路径
|
||||
meta danmuMeta
|
||||
offset *float64
|
||||
opts mergeOptions // 这一组自己的合成参数
|
||||
info mergeInfo
|
||||
}
|
||||
|
||||
@@ -75,6 +102,10 @@ var mergeRun struct {
|
||||
outName string
|
||||
lastErr string
|
||||
doneAt int64
|
||||
// 批量合并的进度:一共几个、已经做完几个、每个的结果
|
||||
total int
|
||||
done int
|
||||
results []mergeJobResult
|
||||
}
|
||||
|
||||
// 手动合并是否正在进行
|
||||
@@ -84,6 +115,15 @@ func mergeRunSnapshot() (busy bool, out string, lastErr string, doneAt int64) {
|
||||
return mergeRun.busy, mergeRun.outName, mergeRun.lastErr, mergeRun.doneAt
|
||||
}
|
||||
|
||||
// 批量合并的队列进度,页面用它显示「第 2/3 个」和各任务的结果
|
||||
func mergeQueueSnapshot() (busy bool, done, total int, results []mergeJobResult) {
|
||||
mergeRun.Lock()
|
||||
defer mergeRun.Unlock()
|
||||
out := make([]mergeJobResult, len(mergeRun.results))
|
||||
copy(out, mergeRun.results)
|
||||
return mergeRun.busy, mergeRun.done, mergeRun.total, out
|
||||
}
|
||||
|
||||
// 解析用户填的路径:可以先当文件名在录播/成片文件夹里找,也可以填绝对或相对路径
|
||||
func resolveMergeInput(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
@@ -278,7 +318,7 @@ func prepareDanmuSource(path string, write bool) (danmuSource, error) {
|
||||
}
|
||||
}
|
||||
|
||||
meta, records, err := parseAssFile(path)
|
||||
meta, records, bad, err := parseAssFile(path)
|
||||
if err != nil {
|
||||
return danmuSource{}, err
|
||||
}
|
||||
@@ -289,6 +329,12 @@ func prepareDanmuSource(path string, write bool) (danmuSource, error) {
|
||||
return src, nil
|
||||
}
|
||||
|
||||
// 还原日志只在真正落盘这次打:页面的「检查」也会解析一遍同一个 .ass,
|
||||
// 两处都打日志的话同一次还原会在日志里出现两遍
|
||||
summary := fmt.Sprintf("已从 %s 还原出 %d 条弹幕(%dx%d,字号 %d,弹幕到 %s)",
|
||||
filepath.Base(path), len(records), meta.PlayResX, meta.PlayResY, meta.FontSize,
|
||||
formatSeconds(records[len(records)-1].Time))
|
||||
|
||||
if err := writeJsonlFile(jsonlPath, meta, records); err != nil {
|
||||
// 目录只读等情况退到临时目录,不影响这次合并
|
||||
tmp := filepath.Join(os.TempDir(), chunkTempPrefix+"converted.jsonl")
|
||||
@@ -298,12 +344,22 @@ func prepareDanmuSource(path string, write bool) (danmuSource, error) {
|
||||
lPrintWarnf("无法把还原结果写入 %s(%v),改用临时文件 %s", jsonlPath, err, tmp)
|
||||
src.jsonlPath = tmp
|
||||
src.tempJsonl = true
|
||||
lPrintf("%s,写成 %s", summary, filepath.Base(tmp))
|
||||
logAssBadLines(path, bad)
|
||||
return src, nil
|
||||
}
|
||||
lPrintf("已把 %s 还原成 %s", filepath.Base(path), filepath.Base(jsonlPath))
|
||||
lPrintf("%s,写成 %s", summary, filepath.Base(jsonlPath))
|
||||
logAssBadLines(path, bad)
|
||||
return src, nil
|
||||
}
|
||||
|
||||
// .ass 里有行没能解析出来时提醒一次(只在真正落盘那次调,避免重复)
|
||||
func logAssBadLines(path string, bad int) {
|
||||
if bad > 0 {
|
||||
lPrintWarnf("%s 里有 %d 行弹幕没能解析出来,已跳过", filepath.Base(path), bad)
|
||||
}
|
||||
}
|
||||
|
||||
// .ass 还原结果退到系统临时目录时,用完就删。只删标记过的临时文件:
|
||||
// 正常情况 jsonlPath 是用户录播目录里的同名 jsonl,删了就是丢数据
|
||||
func dropTempDanmu(src danmuSource) {
|
||||
@@ -343,9 +399,44 @@ func freeOutPath(dir, base string) string {
|
||||
return cand
|
||||
}
|
||||
|
||||
// 把一次请求拆成若干个合并任务并逐个检查。全部检查通过才返回:
|
||||
// 排到第 3 个才发现文件不对、前两个已经合完了,这种情况很难跟用户解释
|
||||
func buildMergePlans(req mergeRequest) ([]mergePlan, error) {
|
||||
if len(req.Jobs) == 0 {
|
||||
plan, err := buildMergePlan(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []mergePlan{plan}, nil
|
||||
}
|
||||
if len(req.Jobs) > maxMergeJobs {
|
||||
return nil, fmt.Errorf("一次最多排 %d 个合并任务,这次有 %d 个", maxMergeJobs, len(req.Jobs))
|
||||
}
|
||||
plans := make([]mergePlan, 0, len(req.Jobs))
|
||||
for i, job := range req.Jobs {
|
||||
plan, err := buildMergePlan(mergeRequest{
|
||||
Videos: job.Videos,
|
||||
Danmu: job.Danmu,
|
||||
Offset: job.Offset,
|
||||
OutName: job.OutName,
|
||||
Options: job.Options,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("第 %d 个任务:%v", i+1, err)
|
||||
}
|
||||
plans = append(plans, plan)
|
||||
}
|
||||
return plans, nil
|
||||
}
|
||||
|
||||
// 解析请求、检查文件并算出合并参数
|
||||
func buildMergePlan(req mergeRequest) (mergePlan, error) {
|
||||
var plan mergePlan
|
||||
ffmpegFile := getFFmpeg()
|
||||
plan.opts = req.Options
|
||||
if msg := req.Options.validate(ffmpegFile); msg != "" {
|
||||
return plan, errors.New(msg)
|
||||
}
|
||||
|
||||
// 视频:一行一个,允许夹带空行
|
||||
var values []string
|
||||
@@ -415,7 +506,6 @@ func buildMergePlan(req mergeRequest) (mergePlan, error) {
|
||||
DanmuHasAvatar: src.hasAvatar,
|
||||
Source: src.source,
|
||||
}
|
||||
ffmpegFile := getFFmpeg()
|
||||
for _, f := range plan.videos {
|
||||
fi, err := os.Stat(f)
|
||||
if err != nil {
|
||||
@@ -460,7 +550,9 @@ func buildMergePlan(req mergeRequest) (mergePlan, error) {
|
||||
if info.Duration > 0 && src.last+info.Offset >= info.Duration {
|
||||
info.Warnings = append(info.Warnings, fmt.Sprintf(
|
||||
"最后一条弹幕在 %.1f 秒,加上偏移后已超出视频总长 %.1f 秒,超出的弹幕会被丢掉;"+
|
||||
"如果这个视频只是整场的一段,请把同场的其它分段一起加进来",
|
||||
"如果这个视频只是整场的一段,请把同场的其它分段一起加进来;"+
|
||||
"如果弹幕整体都落在时长之外,通常是这个弹幕文件的时间基准和视频不一致"+
|
||||
"(合成时会按第一条弹幕自动对齐,日志里有平移量),也可以自己填「偏移」",
|
||||
src.last+info.Offset, info.Duration))
|
||||
}
|
||||
if info.Duration == 0 {
|
||||
@@ -471,8 +563,9 @@ func buildMergePlan(req mergeRequest) (mergePlan, error) {
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// 真正执行合并,进度既打到运行日志里,也同步给 web UI 的任务卡片
|
||||
func runManualMerge(plan mergePlan) error {
|
||||
// 真正执行合并,进度既打到运行日志里,也同步给 web UI 的任务卡片。
|
||||
// idx / total 是批量队列里的位置(单任务传 1, 1),只用来在日志和界面上标「第几个」
|
||||
func runManualMerge(plan mergePlan, idx, total int) error {
|
||||
ffmpegFile := getFFmpeg()
|
||||
if ffmpegFile == "" {
|
||||
return errors.New("找不到 ffmpeg,请先在设置里填写 ffmpeg 路径")
|
||||
@@ -483,8 +576,11 @@ func runManualMerge(plan mergePlan) error {
|
||||
for _, v := range plan.info.Videos {
|
||||
inputBytes += v.Size
|
||||
}
|
||||
if !jobStart("手动合并", plan.info.OutName, inputBytes, plan.info.Duration) {
|
||||
return errors.New("已经有别的合并任务在跑,请先等它结束")
|
||||
if !jobStart(jobKindManualMerge, plan.info.OutName, inputBytes, plan.info.Duration) {
|
||||
return errJobNotStarted
|
||||
}
|
||||
if idx > 0 && total > 0 {
|
||||
jobQueue(idx, total)
|
||||
}
|
||||
|
||||
src, err := prepareDanmuSource(plan.danmu, true)
|
||||
@@ -543,13 +639,18 @@ func runManualMerge(plan mergePlan) error {
|
||||
// 成片目录可能是网络盘或外接盘,直接往里写、写到一半失败会在那里留个半成品
|
||||
tmpOut := tempMergeOutput(plan.danmu, filepath.Base(plan.out))
|
||||
|
||||
lPrintf("手动合并开始:视频 %s,弹幕 %s,偏移 %.3f 秒(%s)",
|
||||
filepath.Base(video), filepath.Base(jsonl), offset, note)
|
||||
// 批量合并时在日志里标出这是第几个,翻日志的时候对得上号
|
||||
tag := ""
|
||||
if total > 1 {
|
||||
tag = fmt.Sprintf("(第 %d/%d 个)", idx, total)
|
||||
}
|
||||
lPrintf("手动合并开始%s:视频 %s,弹幕 %s,偏移 %.3f 秒(%s)",
|
||||
tag, filepath.Base(video), filepath.Base(jsonl), offset, note)
|
||||
lPrintf("先在本地合成 %s,完成后落到 %s", filepath.Base(tmpOut), plan.out)
|
||||
|
||||
// 标记成正在写入,避免在文件管理里把还没写完的成片删掉
|
||||
markFileActive(tmpOut, true)
|
||||
burnErr := burnDanmu(jsonl, video, tmpOut, offset)
|
||||
burnErr := burnDanmu(jsonl, video, tmpOut, offset, plan.opts)
|
||||
markFileActive(tmpOut, false)
|
||||
if burnErr != nil {
|
||||
_ = os.Remove(tmpOut)
|
||||
@@ -561,7 +662,7 @@ func runManualMerge(plan mergePlan) error {
|
||||
return err
|
||||
}
|
||||
|
||||
lPrintln("手动合并完成:" + plan.out)
|
||||
lPrintln("手动合并完成" + tag + ":" + plan.out)
|
||||
// 通知:手动合并没有 uid,能从文件名对上哪个主播就带上名字,对不上就用通用文案
|
||||
uid := guessUIDByFileName(plan.out)
|
||||
if uid == 0 {
|
||||
@@ -571,24 +672,38 @@ func runManualMerge(plan mergePlan) error {
|
||||
if uid > 0 {
|
||||
text = longID(uid) + " 的录播和弹幕(手动合并)完成\n" + plan.out
|
||||
}
|
||||
if total > 1 {
|
||||
text += fmt.Sprintf("\n(第 %d/%d 个)", idx, total)
|
||||
}
|
||||
sendNotify(notifyEventMergeDone, uid, nameForLog(uid), "", "", plan.out, text)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 检查合并参数,回显时长、弹幕条数和推测的偏移,不做实际合成
|
||||
// 检查合并参数,回显时长、弹幕条数和推测的偏移,不做实际合成。
|
||||
// 请求里带 jobs 时一次检查多个任务,返回 {jobs:[...]};单个任务仍直接返回 info
|
||||
func mergeInfoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req mergeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
plan, err := buildMergePlan(req)
|
||||
plans, err := buildMergePlans(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(plan.info); err != nil {
|
||||
if len(req.Jobs) == 0 {
|
||||
if err := json.NewEncoder(w).Encode(plans[0].info); err != nil {
|
||||
lPrintErrf("输出合并检查结果失败:%v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
infos := make([]mergeInfo, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
infos = append(infos, p.info)
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{"jobs": infos}); err != nil {
|
||||
lPrintErrf("输出合并检查结果失败:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -600,7 +715,7 @@ func mergeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
plan, err := buildMergePlan(req)
|
||||
plans, err := buildMergePlans(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
@@ -624,10 +739,14 @@ func mergeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
mergeRun.busy = true
|
||||
mergeRun.outName = plan.info.OutName
|
||||
mergeRun.outName = plans[0].info.OutName
|
||||
mergeRun.lastErr = ""
|
||||
mergeRun.total = len(plans)
|
||||
mergeRun.done = 0
|
||||
mergeRun.results = nil
|
||||
mergeRun.Unlock()
|
||||
|
||||
total := len(plans)
|
||||
safeGo("手动合并", func() {
|
||||
defer releaseMergeSlot()
|
||||
defer func() {
|
||||
@@ -636,33 +755,65 @@ func mergeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
mergeRun.doneAt = time.Now().Unix()
|
||||
mergeRun.Unlock()
|
||||
}()
|
||||
err := runManualMerge(plan)
|
||||
// 任务卡片挂在这条链路上,无论成败都要收尾,否则进度会一直留在界面上
|
||||
jobFinish(jobErrText(err))
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
msg := err.Error()
|
||||
switch err {
|
||||
case errNoDanmu:
|
||||
msg = "弹幕文件里没有弹幕"
|
||||
lPrintWarn("弹幕文件里没有弹幕,取消手动合并")
|
||||
case errJobStopped:
|
||||
// 手动停止不是失败,别让界面弹「合并失败」
|
||||
lPrintWarn("手动合并已被手动停止")
|
||||
stopped := false
|
||||
for i, plan := range plans {
|
||||
mergeRun.Lock()
|
||||
mergeRun.lastErr = ""
|
||||
mergeRun.outName = plan.info.OutName
|
||||
mergeRun.done = i
|
||||
mergeRun.Unlock()
|
||||
return
|
||||
default:
|
||||
lPrintErrf("手动合并失败:%v", err)
|
||||
notifyMergeFailure(guessUIDByFileName(plan.out), plan.out, err)
|
||||
|
||||
err := runManualMerge(plan, i+1, total)
|
||||
// 任务卡片挂在这条链路上,无论成败都要收尾,否则进度会一直留在界面上。
|
||||
// 只有真的起了任务才收尾:没起成说明占着卡片的是别的任务,别把人家的进度清掉
|
||||
if err != errJobNotStarted {
|
||||
jobFinish(jobErrText(err))
|
||||
}
|
||||
if err == nil {
|
||||
mergeRun.Lock()
|
||||
mergeRun.results = append(mergeRun.results, mergeJobResult{OutName: plan.info.OutName})
|
||||
mergeRun.Unlock()
|
||||
continue
|
||||
}
|
||||
msg := err.Error()
|
||||
switch err {
|
||||
case errNoDanmu:
|
||||
msg = "弹幕文件里没有弹幕"
|
||||
lPrintWarn("弹幕文件里没有弹幕,跳过这个任务")
|
||||
case errJobStopped:
|
||||
// 手动停止不是失败,别让界面弹「合并失败」;排在后面的任务也不再接着做
|
||||
lPrintWarn("手动合并已被手动停止,剩下的任务不再继续")
|
||||
stopped = true
|
||||
default:
|
||||
lPrintErrf("手动合并失败:%v", err)
|
||||
notifyMergeFailure(guessUIDByFileName(plan.out), plan.out, err)
|
||||
}
|
||||
mergeRun.Lock()
|
||||
mergeRun.results = append(mergeRun.results, mergeJobResult{OutName: plan.info.OutName, Err: msg})
|
||||
mergeRun.lastErr = msg
|
||||
mergeRun.Unlock()
|
||||
if stopped {
|
||||
break
|
||||
}
|
||||
}
|
||||
mergeRun.Lock()
|
||||
mergeRun.lastErr = msg
|
||||
mergeRun.done = len(mergeRun.results)
|
||||
mergeRun.Unlock()
|
||||
if total > 1 {
|
||||
ok, failed := 0, 0
|
||||
mergeRun.Lock()
|
||||
for _, r := range mergeRun.results {
|
||||
if r.ok() {
|
||||
ok++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
mergeRun.Unlock()
|
||||
lPrintf("手动合并队列结束:共 %d 个,成功 %d 个,失败 %d 个", total, ok, failed)
|
||||
}
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"outName":` + strconv.Quote(plan.info.OutName) + `}`))
|
||||
_, _ = w.Write([]byte(`{"count":` + strconv.Itoa(total) +
|
||||
`,"outName":` + strconv.Quote(plans[0].info.OutName) + `}`))
|
||||
}
|
||||
|
||||
+129
-5
@@ -68,7 +68,7 @@ Dialogue: 0,0:00:03.50,0:00:13.50,Danmu,丙(303),20,20,2,,{\move(400,24,-40,24)}
|
||||
// (正是它的职责),测试就会莫名其妙失败
|
||||
func execManualMerge(plan mergePlan) error {
|
||||
jobFinish("")
|
||||
err := runMergeExclusive(func() error { return runManualMerge(plan) })
|
||||
err := runMergeExclusive(func() error { return runManualMerge(plan, 1, 1) })
|
||||
jobFinish(jobErrText(err))
|
||||
return err
|
||||
}
|
||||
@@ -163,7 +163,7 @@ func TestParseAssFilePlain(t *testing.T) {
|
||||
dir := setupMergeTest(t)
|
||||
path := writeTestFile(t, filepath.Join(dir, "2026-09-25 01-00-00 测试主播 标题.ass"), plainAssFixture)
|
||||
|
||||
meta, records, err := parseAssFile(path)
|
||||
meta, records, _, err := parseAssFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败:%v", err)
|
||||
}
|
||||
@@ -203,7 +203,7 @@ func TestParseAssFileNick(t *testing.T) {
|
||||
dir := setupMergeTest(t)
|
||||
path := writeTestFile(t, filepath.Join(dir, "2026-09-25 01-00-00 测试主播 标题.nick.ass"), nickAssFixture)
|
||||
|
||||
meta, records, err := parseAssFile(path)
|
||||
meta, records, _, err := parseAssFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败:%v", err)
|
||||
}
|
||||
@@ -238,7 +238,7 @@ func TestParseAssFileWithoutFormatLine(t *testing.T) {
|
||||
dir := setupMergeTest(t)
|
||||
path := writeTestFile(t, filepath.Join(dir, "noformat.ass"), noFormatAssFixture)
|
||||
|
||||
meta, records, err := parseAssFile(path)
|
||||
meta, records, _, err := parseAssFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败:%v", err)
|
||||
}
|
||||
@@ -267,7 +267,7 @@ func TestParseAssFileRejectsBadFile(t *testing.T) {
|
||||
}
|
||||
for name, content := range cases {
|
||||
path := writeTestFile(t, filepath.Join(dir, name), content)
|
||||
if _, _, err := parseAssFile(path); err == nil {
|
||||
if _, _, _, err := parseAssFile(path); err == nil {
|
||||
t.Fatalf("%s 应该报错", name)
|
||||
}
|
||||
}
|
||||
@@ -844,3 +844,127 @@ func TestFindDanmuForVideo(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 一次排多个合并任务 ====================
|
||||
|
||||
// 造一组能过检查的「视频 + 弹幕」:视频随便写点字节,弹幕用真能解析的 jsonl
|
||||
// 文件要落在录播目录里,因为请求里只带文件名,resolveMergeInput 按录播目录找
|
||||
func writeMergePair(t *testing.T, name string) (string, string) {
|
||||
t.Helper()
|
||||
video := filepath.Join(recordSaveDir(), name+".mp4")
|
||||
writeTestFile(t, video, "video")
|
||||
jsonl := filepath.Join(recordSaveDir(), name+".jsonl")
|
||||
writeTestFile(t, jsonl,
|
||||
"{\"playResX\":1920,\"playResY\":1080,\"fontSize\":48,\"startTime\":0}\n"+
|
||||
"{\"uid\":101,\"content\":\"第一条\",\"time\":1,\"lane\":0}\n")
|
||||
return filepath.Base(video), filepath.Base(jsonl)
|
||||
}
|
||||
|
||||
// 批量请求要能拆成多个方案,且每个方案各自带上自己的参数
|
||||
func TestBuildMergePlansBatch(t *testing.T) {
|
||||
setupMergeTest(t)
|
||||
v1, d1 := writeMergePair(t, "2026-09-25 01-00-00 甲 第一场")
|
||||
v2, d2 := writeMergePair(t, "2026-09-25 02-00-00 乙 第二场")
|
||||
|
||||
br1, fps2 := 1500, 60
|
||||
plans, err := buildMergePlans(mergeRequest{Jobs: []mergeJobReq{
|
||||
{Videos: []string{v1}, Danmu: d1, Options: mergeOptions{Bitrate: &br1}},
|
||||
{Videos: []string{v2}, Danmu: d2, Options: mergeOptions{Fps: &fps2}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("批量检查失败:%v", err)
|
||||
}
|
||||
if len(plans) != 2 {
|
||||
t.Fatalf("应拆成 2 个方案,实际 %d", len(plans))
|
||||
}
|
||||
if plans[0].opts.bitrate() != 1500 {
|
||||
t.Errorf("第一个任务的码率没带上:%d", plans[0].opts.bitrate())
|
||||
}
|
||||
if plans[1].opts.bitrate() != config.MergeBitrate {
|
||||
t.Errorf("第二个任务没填码率就该用全局设置,实际 %d", plans[1].opts.bitrate())
|
||||
}
|
||||
if plans[1].opts.fps() != 60 {
|
||||
t.Errorf("第二个任务的帧率没带上:%d", plans[1].opts.fps())
|
||||
}
|
||||
// 输出名各自独立
|
||||
if plans[0].info.OutName == plans[1].info.OutName {
|
||||
t.Errorf("两个任务的输出名撞了:%s", plans[0].info.OutName)
|
||||
}
|
||||
|
||||
// 单个任务(老写法)仍然只出一个方案
|
||||
one, err := buildMergePlans(mergeRequest{Videos: []string{v1}, Danmu: d1})
|
||||
if err != nil || len(one) != 1 {
|
||||
t.Fatalf("单个任务应只出 1 个方案,实际 %d(err=%v)", len(one), err)
|
||||
}
|
||||
}
|
||||
|
||||
// 排在后面的任务有问题时要连序号一起报出来,并且一个都不该开始
|
||||
func TestBuildMergePlansReportsBadIndex(t *testing.T) {
|
||||
setupMergeTest(t)
|
||||
v1, d1 := writeMergePair(t, "2026-09-25 01-00-00 甲 第一场")
|
||||
|
||||
_, err := buildMergePlans(mergeRequest{Jobs: []mergeJobReq{
|
||||
{Videos: []string{v1}, Danmu: d1},
|
||||
{Videos: []string{v1}, Danmu: "不存在的弹幕.jsonl"},
|
||||
}})
|
||||
if err == nil {
|
||||
t.Fatal("第二个任务的文件不存在,应当报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "第 2 个任务") {
|
||||
t.Errorf("报错里该带上序号,实际:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 一次排太多要挡住,别让人不小心把几十场全排上
|
||||
func TestBuildMergePlansCapsCount(t *testing.T) {
|
||||
setupMergeTest(t)
|
||||
v1, d1 := writeMergePair(t, "2026-09-25 01-00-00 甲 第一场")
|
||||
jobs := make([]mergeJobReq, 0, maxMergeJobs+1)
|
||||
for i := 0; i < maxMergeJobs+1; i++ {
|
||||
jobs = append(jobs, mergeJobReq{Videos: []string{v1}, Danmu: d1})
|
||||
}
|
||||
if _, err := buildMergePlans(mergeRequest{Jobs: jobs}); err == nil {
|
||||
t.Errorf("超过 %d 个任务应当被挡住", maxMergeJobs)
|
||||
}
|
||||
}
|
||||
|
||||
// 逐任务参数的兜底和校验
|
||||
func TestMergeOptionsFallbackAndValidate(t *testing.T) {
|
||||
ff := testFFmpeg(t)
|
||||
config.MergeBitrate, config.MergeFps, config.DanmuFontSize = 2000, 30, 42
|
||||
defer func() { config.MergeBitrate, config.MergeFps, config.DanmuFontSize = 0, 0, 0 }()
|
||||
|
||||
var empty mergeOptions
|
||||
if empty.bitrate() != 2000 || empty.fps() != 30 || empty.fontSize() != 42 {
|
||||
t.Errorf("没填的项应回落到全局设置,实际 %d / %d / %d",
|
||||
empty.bitrate(), empty.fps(), empty.fontSize())
|
||||
}
|
||||
// 填 0 是有明确含义的,不能被当成「没填」
|
||||
zero := 0
|
||||
if (mergeOptions{Bitrate: &zero}).bitrate() != 0 {
|
||||
t.Error("填了 0 就应当是 0(自动码率),不是全局设置的值")
|
||||
}
|
||||
if msg := (mergeOptions{Fps: &zero}).validate(ff); msg != "" {
|
||||
t.Errorf("帧率 0(跟随源片)不该被挡:%s", msg)
|
||||
}
|
||||
if msg := (mergeOptions{Encoder: "auto"}).validate(ff); msg != "" {
|
||||
t.Errorf("auto 表示自动挑,不该被挡:%s", msg)
|
||||
}
|
||||
bad := -1
|
||||
if msg := (mergeOptions{Bitrate: &bad}).validate(ff); msg == "" {
|
||||
t.Error("负的码率应当被挡住")
|
||||
}
|
||||
over := maxMergeFps + 1
|
||||
if msg := (mergeOptions{Fps: &over}).validate(ff); msg == "" {
|
||||
t.Error("超出上限的帧率应当被挡住")
|
||||
}
|
||||
if msg := (mergeOptions{Encoder: "av1_nvenc"}).validate(ff); msg == "" {
|
||||
t.Error("本机没有的编码器应当被挡住")
|
||||
}
|
||||
// 本机可用的编码器要放行
|
||||
for _, e := range availableEncoders(ff) {
|
||||
if msg := (mergeOptions{Encoder: e}).validate(ff); msg != "" {
|
||||
t.Errorf("本机可用的 %s 不该被挡:%s", e, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -727,7 +727,7 @@ func TestManualMergeSendsMergeDoneNotify(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("检查失败:%v", err)
|
||||
}
|
||||
if err := runManualMerge(plan); err != nil {
|
||||
if err := runManualMerge(plan, 1, 1); err != nil {
|
||||
t.Fatalf("手动合并失败:%v", err)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -175,7 +175,7 @@ func TestParseAssFileAcceptsBOM(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
meta, records, err := parseAssFile(p)
|
||||
meta, records, _, err := parseAssFile(p)
|
||||
if err != nil {
|
||||
t.Fatalf("带 BOM 的 ASS 应当能正常解析,实际报错:%v", err)
|
||||
}
|
||||
|
||||
+76
-4
@@ -14,6 +14,13 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// 任务类型。阶段划分按它来取(见 jobPhasePlanFor)
|
||||
const (
|
||||
jobKindManualMerge = "手动合并"
|
||||
jobKindAutoMerge = "自动合并"
|
||||
jobKindTranscode = "转码"
|
||||
)
|
||||
|
||||
// 任务阶段,前端按这个顺序画步骤条
|
||||
var jobPhaseNames = []string{"准备", "分段拼接", "获取头像", "画面渲染", "拼接与落盘"}
|
||||
|
||||
@@ -30,6 +37,36 @@ const (
|
||||
// 其余阶段按经验估:拼接分段与头像下载是分钟级,准备和落盘是秒级。
|
||||
var jobPhaseWeights = []float64{0.02, 0.08, 0.10, 0.75, 0.05}
|
||||
|
||||
// 一类任务的阶段划分。转码没有弹幕和头像,步骤比合并少,硬套合并那套步骤条
|
||||
// 会冒出两个永远走不到的空阶段
|
||||
type jobPhasePlan struct {
|
||||
names []string
|
||||
weights []float64
|
||||
render int // 哪个阶段是真正耗时的主阶段,ETA 按它算
|
||||
}
|
||||
|
||||
// 合并类任务的阶段(默认),转码类任务的阶段
|
||||
var (
|
||||
mergePhasePlan = jobPhasePlan{names: jobPhaseNames, weights: jobPhaseWeights, render: jobPhaseRender}
|
||||
// 转码:探测一下参数就开始编,编完落到成片目录
|
||||
transcodePhasePlan = jobPhasePlan{
|
||||
names: []string{"准备", "转码", "落盘"},
|
||||
weights: []float64{0.03, 0.92, 0.05},
|
||||
render: 1,
|
||||
}
|
||||
)
|
||||
|
||||
// 转码任务阶段里「转码」这一步的序号
|
||||
const jobPhaseTranscode = 1
|
||||
|
||||
// 按任务类型取阶段划分,没见过的类型按合并处理
|
||||
func jobPhasePlanFor(kind string) jobPhasePlan {
|
||||
if kind == jobKindTranscode {
|
||||
return transcodePhasePlan
|
||||
}
|
||||
return mergePhasePlan
|
||||
}
|
||||
|
||||
// 渲染进度的采样间隔与折线最多保留的点数(1 秒 1 个,够画两分钟)
|
||||
const (
|
||||
jobSampleInterval = time.Second
|
||||
@@ -39,6 +76,10 @@ const (
|
||||
// 任务被手动停止时各层返回这个错误:调用方据此清理临时文件,不再回退其它编码器
|
||||
var errJobStopped = errors.New("任务已被手动停止")
|
||||
|
||||
// 起任务时任务位被别人占着:这次没有真的开始,调用方据此跳过 jobFinish,
|
||||
// 不然会把正在跑的那个任务的进度卡片清掉
|
||||
var errJobNotStarted = errors.New("已经有别的任务在跑,请先等它结束")
|
||||
|
||||
// 速度折线上的一个点
|
||||
type jobSample struct {
|
||||
T float64 `json:"t"` // 任务开始后的秒数
|
||||
@@ -64,6 +105,8 @@ type jobInfo struct {
|
||||
ChunkTotal int `json:"chunkTotal"`
|
||||
AvatarDone int `json:"avatarDone"`
|
||||
AvatarTotal int `json:"avatarTotal"`
|
||||
QueueDone int `json:"queueDone"` // 批量任务:已经做完第几个(从 1 开始),单任务为 0
|
||||
QueueTotal int `json:"queueTotal"` // 批量任务:一共几个
|
||||
InputBytes int64 `json:"inputBytes"` // 源视频总大小
|
||||
EstOutBytes int64 `json:"estOutBytes"` // 预计成片大小
|
||||
EstSource string `json:"estSource"` // 预计值的来源:bitrate / measured
|
||||
@@ -90,6 +133,14 @@ type jobState struct {
|
||||
stopped bool
|
||||
proc *exec.Cmd // 当前在跑的 ffmpeg,暂停/停止都作用在它身上
|
||||
|
||||
// 当前任务的阶段划分,随任务类型在 jobStart 时定下来
|
||||
phases []string
|
||||
weights []float64
|
||||
renderIdx int
|
||||
// 批量任务(手动合并队列、转码队列)的进度,单任务时都是 0
|
||||
queueDone int
|
||||
queueTotal int
|
||||
|
||||
totalSec float64
|
||||
doneSec float64
|
||||
speed float64
|
||||
@@ -163,6 +214,10 @@ func jobStart(kind, outName string, inputBytes int64, totalSec float64) bool {
|
||||
job.active = true
|
||||
job.kind = kind
|
||||
job.outName = outName
|
||||
plan := jobPhasePlanFor(kind)
|
||||
job.phases = plan.names
|
||||
job.weights = plan.weights
|
||||
job.renderIdx = plan.render
|
||||
job.phaseIdx = 0
|
||||
job.phasePct = 0
|
||||
job.started = time.Now()
|
||||
@@ -175,6 +230,7 @@ func jobStart(kind, outName string, inputBytes int64, totalSec float64) bool {
|
||||
job.fps = 0
|
||||
job.chunkDone, job.chunkTotal = 0, 0
|
||||
job.avatarDone, job.avatarTotal = 0, 0
|
||||
job.queueDone, job.queueTotal = 0, 0
|
||||
job.inputBytes = inputBytes
|
||||
job.planOutBytes = 0
|
||||
job.liveOutBytes = 0
|
||||
@@ -217,7 +273,7 @@ func jobFinish(errText string) {
|
||||
job.doneAt = time.Now().Unix()
|
||||
switch errText {
|
||||
case "":
|
||||
job.phaseIdx = len(jobPhaseNames)
|
||||
job.phaseIdx = len(job.phases)
|
||||
job.phasePct = 1
|
||||
job.message = "已完成"
|
||||
case errJobStopped.Error():
|
||||
@@ -346,6 +402,16 @@ func jobAvatars(done, total int) {
|
||||
}
|
||||
}
|
||||
|
||||
// 批量任务(手动合并队列、转码队列)排到第几个。done 从 1 开始,total 是总数
|
||||
func jobQueue(done, total int) {
|
||||
job.Lock()
|
||||
defer job.Unlock()
|
||||
if !job.active {
|
||||
return
|
||||
}
|
||||
job.queueDone, job.queueTotal = done, total
|
||||
}
|
||||
|
||||
// 设置任务总时长(视频秒数),ETA 和渲染百分比都靠它
|
||||
func jobSetTotal(sec float64) {
|
||||
job.Lock()
|
||||
@@ -481,8 +547,12 @@ func clamp01(v float64) float64 {
|
||||
|
||||
// 总进度:已走完的阶段按权重全算,当前阶段按阶段内比例折算
|
||||
func (j *jobState) percentLocked() float64 {
|
||||
weights := j.weights
|
||||
if len(weights) == 0 {
|
||||
weights = jobPhaseWeights
|
||||
}
|
||||
p := 0.0
|
||||
for i, w := range jobPhaseWeights {
|
||||
for i, w := range weights {
|
||||
switch {
|
||||
case i < j.phaseIdx:
|
||||
p += w
|
||||
@@ -498,7 +568,7 @@ func (j *jobState) etaLocked(now time.Time) float64 {
|
||||
if !j.active {
|
||||
return 0
|
||||
}
|
||||
if j.phaseIdx == jobPhaseRender && j.speed > 0 && j.totalSec > j.doneSec {
|
||||
if j.phaseIdx == j.renderIdx && j.speed > 0 && j.totalSec > j.doneSec {
|
||||
return (j.totalSec - j.doneSec) / j.speed
|
||||
}
|
||||
pct := j.percentLocked()
|
||||
@@ -518,7 +588,7 @@ func jobSnapshot() jobInfo {
|
||||
Kind: job.kind,
|
||||
OutName: job.outName,
|
||||
PhaseIdx: job.phaseIdx,
|
||||
Phases: jobPhaseNames,
|
||||
Phases: job.phases,
|
||||
Paused: job.paused,
|
||||
Stopping: job.stopped,
|
||||
Speed: job.speed,
|
||||
@@ -529,6 +599,8 @@ func jobSnapshot() jobInfo {
|
||||
ChunkTotal: job.chunkTotal,
|
||||
AvatarDone: job.avatarDone,
|
||||
AvatarTotal: job.avatarTotal,
|
||||
QueueDone: job.queueDone,
|
||||
QueueTotal: job.queueTotal,
|
||||
InputBytes: job.inputBytes,
|
||||
OutBytes: job.outBytes,
|
||||
Message: job.message,
|
||||
|
||||
+30
-3
@@ -8,12 +8,28 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/orzogc/acfundanmu"
|
||||
)
|
||||
|
||||
// 等替换过的后台任务全部收工。用例里触发了 fire-and-forget 的合并时,必须在用例结束前等它们跑完
|
||||
func waitBackground(t *testing.T, bg *sync.WaitGroup, timeout time.Duration) {
|
||||
t.Helper()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
bg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(timeout):
|
||||
t.Error("等后台任务收工超时,它可能还在读全局配置")
|
||||
}
|
||||
}
|
||||
|
||||
// 用真正的录制写入器(danmuWriter)产生 jsonl,再按合并的流程重排一遍,
|
||||
// 验证「本程序自己录下来的弹幕」合出来和以前一模一样(弹道、宽度都不变)。
|
||||
// 这也是手动合并与自动合并的分水岭:录制侧写文件完全不经过 relayoutLanes,
|
||||
@@ -183,12 +199,23 @@ func TestDanmuWriteNoFalseAlarm(t *testing.T) {
|
||||
// 弹幕文件写坏之后必须跳过自动合并:只是打一条日志的话,
|
||||
// 照样会合成一个「缺了一大截弹幕」的成片,而且是静默的
|
||||
func TestFinishPartSkipsMergeWhenDanmuBroken(t *testing.T) {
|
||||
// 这个用例会真的触发自动合并(safeGo 起的 goroutine)。它跑到用例结束之后还会读全局配置,
|
||||
// 而下一个用例开头就改配置 → -race 会判成数据竞争。所以这里把后台任务换成可等待的版本,
|
||||
// 清理时先等它收工再还原全局变量
|
||||
var bg sync.WaitGroup
|
||||
oldRun := runBackground
|
||||
runBackground = func(f func()) {
|
||||
bg.Add(1)
|
||||
go func() {
|
||||
defer bg.Done()
|
||||
f()
|
||||
}()
|
||||
}
|
||||
oldAuto, oldCtx := config.AutoMerge, mainCtx
|
||||
t.Cleanup(func() {
|
||||
waitBackground(t, &bg, 60*time.Second)
|
||||
config.AutoMerge, mainCtx = oldAuto, oldCtx
|
||||
// 等后台那两个(可能启动了的)合并跑完,别把任务状态留给后面的用例
|
||||
takeMergeSlot()
|
||||
releaseMergeSlot()
|
||||
runBackground = oldRun
|
||||
})
|
||||
config.AutoMerge = true
|
||||
mainCtx = context.Background()
|
||||
|
||||
+1047
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,605 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ==================== 参数校验 ====================
|
||||
|
||||
// 没填的项要补成默认值,不然拼出来的 ffmpeg 命令会带着一堆空参数
|
||||
func TestTranscodeOptionsNormalize(t *testing.T) {
|
||||
o := transcodeOptions{}
|
||||
n := o.normalize()
|
||||
if n.Mode != rateModeSource {
|
||||
t.Errorf("模式空时应按「跟随源片」,实际 %q", n.Mode)
|
||||
}
|
||||
if n.Preset != defaultTranscodePreset {
|
||||
t.Errorf("预设空时应取 %q,实际 %q", defaultTranscodePreset, n.Preset)
|
||||
}
|
||||
if n.Audio != audioCopy {
|
||||
t.Errorf("音频空时应按直接拷贝,实际 %q", n.Audio)
|
||||
}
|
||||
}
|
||||
|
||||
// 各种填错的情况都要在开跑之前挡住,别等 ffmpeg 报一句看不懂的错
|
||||
func TestTranscodeOptionsValidate(t *testing.T) {
|
||||
ff := testFFmpeg(t)
|
||||
bad := map[string]transcodeOptions{
|
||||
"模式不认识": {Mode: "xyz"},
|
||||
"选了码率却没填": {Mode: rateModeBitrate},
|
||||
"质量值越界": {Mode: rateModeQuality, Quality: 99},
|
||||
"比例越界": {Mode: rateModePercent, Percent: 1000},
|
||||
"码率上界越界": {Mode: rateModeBitrate, Bitrate: maxBitrateKbps + 1},
|
||||
"预设不认识": {Preset: "ultrafast"},
|
||||
"帧率越界": {Fps: maxMergeFps + 1},
|
||||
"缩放高度太小": {Scale: 10},
|
||||
"音频处理不认识": {Audio: "mp3"},
|
||||
"编码器本机没有": {Encoder: "av1_nvenc"},
|
||||
}
|
||||
for name, o := range bad {
|
||||
if msg := o.validate(ff); msg == "" {
|
||||
t.Errorf("%s:应当被挡住,实际通过了", name)
|
||||
}
|
||||
}
|
||||
// 正常参数要能过
|
||||
ok := transcodeOptions{Mode: rateModeBitrate, Bitrate: 2000, Preset: "fast", Fps: 30, Scale: 720, Audio: audioAAC128}
|
||||
if msg := ok.validate(ff); msg != "" {
|
||||
t.Errorf("正常参数不该被挡:%s", msg)
|
||||
}
|
||||
if msg := (transcodeOptions{Encoder: "auto"}).validate(ff); msg != "" {
|
||||
t.Errorf("auto 表示自动挑,不该被挡:%s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 体积估算 ====================
|
||||
|
||||
// 目标码率模式的体积是能算准的:码率 × 时长
|
||||
func TestEstimateTranscodeBitrateMode(t *testing.T) {
|
||||
p := transcodeProbe{Duration: 100, VideoKbps: 4000, AudioKbps: 128}
|
||||
got := estimateTranscode(transcodeOptions{Mode: rateModeBitrate, Bitrate: 2000, Audio: audioAAC128}, p, "h264_qsv")
|
||||
// 视频 2000 + 音频 128 = 2128 kbps × 100 秒 = 26.6 MB
|
||||
want := int64((2000 + 128) * 1000 / 8 * 100)
|
||||
if got.Bytes != want {
|
||||
t.Errorf("预估体积应为 %d,实际 %d", want, got.Bytes)
|
||||
}
|
||||
if !got.Exact {
|
||||
t.Error("目标码率模式算出来的是准数,不该标成粗估")
|
||||
}
|
||||
if got.VideoKbps != 2000 || got.AudioKbps != 128 {
|
||||
t.Errorf("预估码率不对:视频 %d、音频 %d", got.VideoKbps, got.AudioKbps)
|
||||
}
|
||||
}
|
||||
|
||||
// 按比例模式:60% 就是源片的六成
|
||||
func TestEstimateTranscodePercentMode(t *testing.T) {
|
||||
p := transcodeProbe{Duration: 60, VideoKbps: 5000}
|
||||
got := estimateTranscode(transcodeOptions{Mode: rateModePercent, Percent: 60, Audio: audioNone}, p, "libx264")
|
||||
want := int64(3000 * 1000 / 8 * 60)
|
||||
if got.Bytes != want {
|
||||
t.Errorf("60%% 的源片码率应为 %d,实际 %d", want, got.Bytes)
|
||||
}
|
||||
if got.AudioKbps != 0 {
|
||||
t.Errorf("去掉音轨后音频码率应为 0,实际 %d", got.AudioKbps)
|
||||
}
|
||||
}
|
||||
|
||||
// 质量模式只能粗估:数值越大体积越小,且要标成粗估
|
||||
func TestEstimateTranscodeQualityMode(t *testing.T) {
|
||||
p := transcodeProbe{Duration: 100, VideoKbps: 4000, AudioKbps: 128}
|
||||
low := estimateTranscode(transcodeOptions{Mode: rateModeQuality, Quality: 18}, p, "libx264")
|
||||
high := estimateTranscode(transcodeOptions{Mode: rateModeQuality, Quality: 28}, p, "libx264")
|
||||
if low.Bytes <= high.Bytes {
|
||||
t.Errorf("质量值 18(%d 字节)应比 28(%d 字节)体积大", low.Bytes, high.Bytes)
|
||||
}
|
||||
if low.Exact || high.Exact {
|
||||
t.Error("质量模式只能粗估,不该标成准数")
|
||||
}
|
||||
if high.Note == "" {
|
||||
t.Error("粗估要给出依据说明,界面上要能看出来这不是准数")
|
||||
}
|
||||
}
|
||||
|
||||
// 质量值的系数表要单调:数值越大系数越小
|
||||
func TestQualityFactorMonotonic(t *testing.T) {
|
||||
prev := math.Inf(1)
|
||||
for q := 0; q <= 51; q++ {
|
||||
f := qualityFactor(q)
|
||||
if f > prev+1e-9 {
|
||||
t.Fatalf("质量值 %d 的系数 %v 比上一个还大,压得越狠体积越大了", q, f)
|
||||
}
|
||||
if f <= 0 {
|
||||
t.Fatalf("质量值 %d 的系数不该是 %v", q, f)
|
||||
}
|
||||
prev = f
|
||||
}
|
||||
// 表上的点要精确命中
|
||||
if got := qualityFactor(18); math.Abs(got-1.0) > 1e-9 {
|
||||
t.Errorf("CRF 18 是基准,系数应为 1,实际 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 缩到更低分辨率后,同样的码率要求下产物应更小
|
||||
func TestEstimateTranscodeScalingShrinks(t *testing.T) {
|
||||
p := transcodeProbe{Duration: 100, VideoKbps: 4000, Height: 1080}
|
||||
full := estimateTranscode(transcodeOptions{Mode: rateModeSource}, p, "libx264")
|
||||
half := estimateTranscode(transcodeOptions{Mode: rateModeSource, Scale: 540}, p, "libx264")
|
||||
if half.Bytes >= full.Bytes {
|
||||
t.Errorf("缩到 540p(%d)应比原样(%d)小", half.Bytes, full.Bytes)
|
||||
}
|
||||
// 按面积算:540/1080 = 0.5,码率约为四分之一
|
||||
if half.VideoKbps > full.VideoKbps/3 {
|
||||
t.Errorf("高度减半后码率应降到约四分之一,实际 %d → %d", full.VideoKbps, half.VideoKbps)
|
||||
}
|
||||
}
|
||||
|
||||
// 源片没有音轨时别凭空加一份音频码率(拷贝模式拷不出音轨来)
|
||||
func TestEstimateTranscodeNoAudioTrack(t *testing.T) {
|
||||
// HasAudio 没置位 = 探测不到音轨
|
||||
p := transcodeProbe{Duration: 100, VideoKbps: 4000}
|
||||
got := estimateTranscode(transcodeOptions{Mode: rateModeBitrate, Bitrate: 2000, Audio: audioCopy}, p, "libx264")
|
||||
want := int64(2000 * 1000 / 8 * 100)
|
||||
if got.Bytes != want {
|
||||
t.Errorf("没有音轨时不该算音频:应 %d,实际 %d(音频 %d kbps)", want, got.Bytes, got.AudioKbps)
|
||||
}
|
||||
if got.AudioKbps != 0 {
|
||||
t.Errorf("没有音轨时音频码率应为 0,实际 %d", got.AudioKbps)
|
||||
}
|
||||
// 显式要求重编码音频的话仍然按目标算(源片没有也只是多加一条空轨,不至于算错体积量级)
|
||||
got = estimateTranscode(transcodeOptions{Mode: rateModeBitrate, Bitrate: 2000, Audio: audioAAC128}, p, "libx264")
|
||||
if got.AudioKbps != 128 {
|
||||
t.Errorf("显式重编码 128k 时应按 128 算,实际 %d", got.AudioKbps)
|
||||
}
|
||||
}
|
||||
|
||||
// 探测不到视频码率时按「体积 ÷ 时长」倒推,不能算成 0(那会显示成「—」)
|
||||
func TestVideoKbpsFallsBackToFileSize(t *testing.T) {
|
||||
// 100 秒、50 MB 的文件 → 约 4000 kbps,扣掉 128k 音轨
|
||||
m := mediaInfo{duration: 100, hasAudio: true, audioKbps: 128}
|
||||
got := videoKbpsOf(m, 50*1024*1024)
|
||||
if got < 3000 || got > 4500 {
|
||||
t.Errorf("倒推的源片码率应在 3000~4500 之间,实际 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 容器总码率含音频,不能直接当视频码率:算体积时会和音频重复计一遍,
|
||||
// 「跟随源片」时也会把音轨的码率算进视频里
|
||||
func TestVideoKbpsDropsAudioFromContainer(t *testing.T) {
|
||||
m := mediaInfo{containerKbps: 3000, hasAudio: true, audioKbps: 192}
|
||||
if got := videoKbpsOf(m, 0); got != 2808 {
|
||||
t.Errorf("容器 3000 扣掉 192 音轨应为 2808,实际 %d", got)
|
||||
}
|
||||
// 视频流自己写了码率就以它为准,不再动
|
||||
m.videoKbps = 2600
|
||||
if got := videoKbpsOf(m, 0); got != 2600 {
|
||||
t.Errorf("视频流自己写了码率就该用它,实际 %d", got)
|
||||
}
|
||||
// 没有音轨时不扣
|
||||
m = mediaInfo{containerKbps: 3000}
|
||||
if got := videoKbpsOf(m, 0); got != 3000 {
|
||||
t.Errorf("没有音轨时不扣,实际 %d", got)
|
||||
}
|
||||
// 音轨码率探测不到时按经验值扣,别一点不扣
|
||||
m = mediaInfo{containerKbps: 3000, hasAudio: true}
|
||||
if got := videoKbpsOf(m, 0); got != 3000-defaultAudioKbps {
|
||||
t.Errorf("音轨码率未知时应按 %d 扣,实际 %d", defaultAudioKbps, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 合并那边的自动码率一直按「视频流码率,没有就用容器总码率(含音频)」算,
|
||||
// 改口径会让所有自动合并的成片都变小一截,这里锁住
|
||||
func TestStreamOrContainerKbpsKeepsOldBehavior(t *testing.T) {
|
||||
if got := streamOrContainerKbps(mediaInfo{videoKbps: 2000, containerKbps: 2500}); got != 2000 {
|
||||
t.Errorf("有视频流码率时应优先用它,实际 %d", got)
|
||||
}
|
||||
if got := streamOrContainerKbps(mediaInfo{containerKbps: 2500, hasAudio: true, audioKbps: 192}); got != 2500 {
|
||||
t.Errorf("没有视频流码率时应退回容器总码率(含音频),实际 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 探测结果的解析:容器总码率和视频流码率要分开记,封面图不能算成画面
|
||||
func TestParseMediaInfoSplitsBitrates(t *testing.T) {
|
||||
out := "Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'x.mp4':\n" +
|
||||
" Duration: 00:02:55.07, start: 0.000000, bitrate: 1767 kb/s\n" +
|
||||
" Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p, 1920x1080 [SAR 1:1 DAR 16:9], 1234 kb/s, 30 fps, 30 tbr, 15360 tbn\n" +
|
||||
" Stream #0:1[0x2](und): Audio: aac (LC) (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 192 kb/s\n"
|
||||
m := parseMediaInfo(out)
|
||||
if !m.hasVideo {
|
||||
t.Error("有视频轨时 hasVideo 应为 true")
|
||||
}
|
||||
if m.videoKbps != 1234 || m.containerKbps != 1767 {
|
||||
t.Errorf("视频流码率和容器总码率要分开记,实际 %d / %d", m.videoKbps, m.containerKbps)
|
||||
}
|
||||
if m.width != 1920 || m.height != 1080 || m.fps != 30 {
|
||||
t.Errorf("画面参数没解析对:%dx%d %v fps", m.width, m.height, m.fps)
|
||||
}
|
||||
if !m.hasAudio || m.audioKbps != 192 {
|
||||
t.Errorf("音轨没解析对:hasAudio=%v %d kbps", m.hasAudio, m.audioKbps)
|
||||
}
|
||||
if m.codec != "h264" {
|
||||
t.Errorf("编码器名应为 h264,实际 %q", m.codec)
|
||||
}
|
||||
if d := m.duration; d < 175.0 || d > 175.2 {
|
||||
t.Errorf("时长应为 175.07 秒,实际 %v", d)
|
||||
}
|
||||
|
||||
// 只有封面图(attached pic)的文件不算有视频轨,否则转码时会当成画面
|
||||
pic := " Duration: 00:00:10.00, start: 0.000000, bitrate: 128 kb/s\n" +
|
||||
" Stream #0:0[0x1]: Video: mjpeg (attached pic), yuvj420p, 600x600, 90k tbr\n" +
|
||||
" Stream #0:1: Audio: mp3, 44100 Hz, stereo, 128 kb/s\n"
|
||||
pm := parseMediaInfo(pic)
|
||||
if pm.hasVideo {
|
||||
t.Error("封面图不该算视频轨")
|
||||
}
|
||||
if pm.width != 0 {
|
||||
t.Errorf("封面图的分辨率不该当画面用,实际 %d", pm.width)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一个文件反复探测要走缓存(体积估算跟着输入框重发,每次起一个 ffmpeg 会把 CPU 占住),
|
||||
// 但文件变了必须重新探
|
||||
func TestProbeMediaCacheInvalidatesOnChange(t *testing.T) {
|
||||
ff := testFFmpeg(t)
|
||||
setupMergeTest(t)
|
||||
path := filepath.Join(recordSaveDir(), "缓存.mp4")
|
||||
if out, err := exec.Command(ff, "-hide_banner", "-y", "-f", "lavfi", "-i",
|
||||
"testsrc=size=160x120:rate=10:duration=1", "-c:v", "libx264", "-pix_fmt", "yuv420p", path).CombinedOutput(); err != nil {
|
||||
t.Skipf("造不出测试视频,跳过:%v %s", err, out)
|
||||
}
|
||||
|
||||
probeCache.Lock()
|
||||
probeCache.m = nil
|
||||
probeCache.Unlock()
|
||||
|
||||
first := probeMediaCached(ff, path)
|
||||
if first.duration <= 0 {
|
||||
t.Fatalf("探测不到时长:%+v", first)
|
||||
}
|
||||
// 第二次必须命中缓存:键的数量不该变
|
||||
probeCache.Lock()
|
||||
n1 := len(probeCache.m)
|
||||
probeCache.Unlock()
|
||||
if again := probeMediaCached(ff, path); again.duration != first.duration {
|
||||
t.Errorf("命中缓存时结果应一致,实际 %v ≠ %v", again.duration, first.duration)
|
||||
}
|
||||
probeCache.Lock()
|
||||
n2 := len(probeCache.m)
|
||||
probeCache.Unlock()
|
||||
if n1 != 1 || n2 != 1 {
|
||||
t.Errorf("同一个文件第二次探测不该新增缓存项,实际 %d → %d", n1, n2)
|
||||
}
|
||||
|
||||
// 换个更长的片子放同一个路径:体积和修改时间都变了,必须重新探
|
||||
if out, err := exec.Command(ff, "-hide_banner", "-y", "-f", "lavfi", "-i",
|
||||
"testsrc=size=160x120:rate=10:duration=3", "-c:v", "libx264", "-pix_fmt", "yuv420p", path).CombinedOutput(); err != nil {
|
||||
t.Fatalf("重造测试视频失败:%v %s", err, out)
|
||||
}
|
||||
changed := probeMediaCached(ff, path)
|
||||
if changed.duration <= first.duration+0.5 {
|
||||
t.Errorf("文件换成 3 秒的了,时长该跟着变(%v → %v)", first.duration, changed.duration)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ffmpeg 参数 ====================
|
||||
|
||||
// 指定的编码器要被用上;没指定时按本机可用的挑
|
||||
func TestTranscodeArgsEncoder(t *testing.T) {
|
||||
ff := testFFmpeg(t)
|
||||
src := mediaInfo{duration: 10, width: 1920, height: 1080, fps: 30, videoKbps: 4000}
|
||||
|
||||
args, enc, _ := transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Encoder: "libx264"}, src)
|
||||
if enc != "libx264" {
|
||||
t.Fatalf("指定 libx264 就该用它,实际 %s", enc)
|
||||
}
|
||||
if !containsSeq(args, "-c:v", "libx264") {
|
||||
t.Errorf("命令里没有 -c:v libx264:%v", args)
|
||||
}
|
||||
// -c:v 必须在 -i 之后,否则 ffmpeg 会当成输入选项
|
||||
iPos, cPos := indexOf(args, "in.mp4"), indexOf(args, "-c:v")
|
||||
if iPos < 0 || cPos < 0 || cPos < iPos {
|
||||
t.Errorf("-c:v 要放在 -i 之后,实际 %v", args)
|
||||
}
|
||||
|
||||
_, auto, _ := transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{}, src)
|
||||
if auto == "" {
|
||||
t.Fatal("自动模式也该挑出一个编码器")
|
||||
}
|
||||
if !containsSeq(args, "-map", "0:v:0") {
|
||||
t.Errorf("应显式只映射第一条视频流(跳过封面图),实际 %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
// 质量模式下给质量参数、给目标码率模式下给码率参数,两者不能同时出现
|
||||
func TestTranscodeArgsRateControl(t *testing.T) {
|
||||
ff := testFFmpeg(t)
|
||||
src := mediaInfo{duration: 10, width: 1920, height: 1080, fps: 30, videoKbps: 4000}
|
||||
|
||||
args, _, _ := transcodeArgs(ff, "in.mp4", "out.mp4",
|
||||
transcodeOptions{Mode: rateModeBitrate, Bitrate: 1500}, src)
|
||||
if !containsSeq(args, "-b:v", "1500k") {
|
||||
t.Errorf("目标码率模式应给 -b:v 1500k,实际 %v", args)
|
||||
}
|
||||
if has(args, "-crf") || has(args, "-global_quality") {
|
||||
t.Errorf("给了目标码率就不该再给质量参数:%v", args)
|
||||
}
|
||||
|
||||
// 质量模式没填数值时用默认值,不能把 0 塞给 ffmpeg
|
||||
args, _, _ = transcodeArgs(ff, "in.mp4", "out.mp4",
|
||||
transcodeOptions{Mode: rateModeQuality, Encoder: "libx264"}, src)
|
||||
if !containsSeq(args, "-crf", "23") {
|
||||
t.Errorf("软编的质量模式应默认 CRF 23,实际 %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
// 缩放和帧率:只缩不放,帧率变了才加 fps 滤镜
|
||||
func TestTranscodeArgsFilters(t *testing.T) {
|
||||
ff := testFFmpeg(t)
|
||||
src := mediaInfo{duration: 10, width: 1920, height: 1080, fps: 30, videoKbps: 4000}
|
||||
|
||||
args, _, fps := transcodeArgs(ff, "in.mp4", "out.mp4",
|
||||
transcodeOptions{Scale: 720, Fps: 60}, src)
|
||||
vf := valueAfter(args, "-vf")
|
||||
if !strings.Contains(vf, "scale=-2:720") {
|
||||
t.Errorf("缩放应写进 -vf,实际 %q", vf)
|
||||
}
|
||||
if !strings.Contains(vf, "fps=60") {
|
||||
t.Errorf("帧率变化应写进 -vf,实际 %q", vf)
|
||||
}
|
||||
if fps != 60 {
|
||||
t.Errorf("返回的帧率应为 60,实际 %d", fps)
|
||||
}
|
||||
|
||||
// 帧率跟源片一样时不加滤镜,白跑一遍滤镜只会更慢
|
||||
_, _, fps = transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Fps: 30}, src)
|
||||
if fps != 0 {
|
||||
t.Errorf("帧率没变时不该加 fps 滤镜,实际返回 %d", fps)
|
||||
}
|
||||
// 目标比源片高时也不放大
|
||||
_, _, _ = transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Scale: 2160}, src)
|
||||
if vf := valueAfter(transcodeArgsArgs(ff, src), "-vf"); strings.Contains(vf, "scale") {
|
||||
t.Errorf("不该把 1080p 放大到 2160p,实际 %q", vf)
|
||||
}
|
||||
}
|
||||
|
||||
// HEVC 要带 hvc1 标签,否则 Apple 系打不开
|
||||
func TestTranscodeArgsHEVCTag(t *testing.T) {
|
||||
ff := testFFmpeg(t)
|
||||
src := mediaInfo{duration: 10, width: 1920, height: 1080, fps: 30, videoKbps: 4000}
|
||||
args, _, _ := transcodeArgs(ff, "in.mp4", "out.mp4",
|
||||
transcodeOptions{Encoder: "hevc_qsv", Mode: rateModeBitrate, Bitrate: 2000}, src)
|
||||
if !containsSeq(args, "-tag:v", "hvc1") {
|
||||
t.Errorf("HEVC 要写 hvc1,实际 %v", args)
|
||||
}
|
||||
args, _, _ = transcodeArgs(ff, "in.mp4", "out.mp4",
|
||||
transcodeOptions{Encoder: "libx264", Mode: rateModeBitrate, Bitrate: 2000}, src)
|
||||
if has(args, "-tag:v") {
|
||||
t.Errorf("H.264 不该加 hvc1,实际 %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
// 音频的三种处理要各自拼对
|
||||
func TestTranscodeArgsAudio(t *testing.T) {
|
||||
ff := testFFmpeg(t)
|
||||
src := mediaInfo{duration: 10, width: 1280, height: 720, fps: 30, videoKbps: 2000, hasAudio: true, audioKbps: 128}
|
||||
|
||||
args, _, _ := transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Audio: audioNone}, src)
|
||||
if !has(args, "-an") {
|
||||
t.Errorf("去掉音轨应给 -an,实际 %v", args)
|
||||
}
|
||||
args, _, _ = transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Audio: audioAAC192}, src)
|
||||
if !containsSeq(args, "-c:a", "aac") || !containsSeq(args, "-b:a", "192k") {
|
||||
t.Errorf("AAC 192k 没拼对:%v", args)
|
||||
}
|
||||
args, _, _ = transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Audio: audioCopy}, src)
|
||||
if !containsSeq(args, "-c:a", "copy") {
|
||||
t.Errorf("直接拷贝应给 -c:a copy,实际 %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 端到端:真的转一个 ====================
|
||||
|
||||
// 拿 ffmpeg 造一段两秒的测试视频,再按各种参数真转一遍。
|
||||
// 只验证参数和落盘,体积大小不断言(不同机器编码结果不一样)
|
||||
func TestRunTranscodeProducesFile(t *testing.T) {
|
||||
ff := testFFmpeg(t)
|
||||
setupMergeTest(t)
|
||||
src := filepath.Join(recordSaveDir(), "源片.mp4")
|
||||
if out, err := exec.Command(ff, "-hide_banner", "-y", "-f", "lavfi", "-i",
|
||||
"testsrc=size=640x360:rate=30:duration=2", "-c:v", "libx264", "-pix_fmt", "yuv420p", src).CombinedOutput(); err != nil {
|
||||
t.Skipf("造不出测试视频,跳过:%v %s", err, out)
|
||||
}
|
||||
|
||||
jobFinish("")
|
||||
var got string
|
||||
err := runMergeExclusive(func() error {
|
||||
var e error
|
||||
got, e = runTranscode(transcodeJobReq{
|
||||
Dir: filepath.Base(recordSaveDir()), Name: filepath.Base(src),
|
||||
Options: transcodeOptions{Mode: rateModeBitrate, Bitrate: 800, Scale: 360, Preset: "veryfast"},
|
||||
}, mergeSaveDir(), 1, 1)
|
||||
return e
|
||||
})
|
||||
jobFinish(jobErrText(err))
|
||||
if err != nil {
|
||||
t.Fatalf("转码失败:%v", err)
|
||||
}
|
||||
if got != "源片_转码.mp4" {
|
||||
t.Errorf("返回的成片名不对:%q", got)
|
||||
}
|
||||
want := filepath.Join(mergeSaveDir(), "源片_转码.mp4")
|
||||
if _, err := os.Stat(want); err != nil {
|
||||
t.Fatalf("成片没落到 %s:%v", want, err)
|
||||
}
|
||||
// 转出来的文件不能是空壳
|
||||
if n := fileSizeOrZero(want); n < 1024 {
|
||||
t.Errorf("成片只有 %d 字节,不正常", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 只有音轨的文件要当场挡住并说清原因,别等 ffmpeg 报一句「matches no streams」
|
||||
func TestRunTranscodeRejectsAudioOnly(t *testing.T) {
|
||||
ff := testFFmpeg(t)
|
||||
setupMergeTest(t)
|
||||
src := filepath.Join(recordSaveDir(), "只有声音.mp4")
|
||||
if out, err := exec.Command(ff, "-hide_banner", "-y", "-f", "lavfi", "-i",
|
||||
"sine=frequency=440:duration=1", "-c:a", "aac", "-vn", src).CombinedOutput(); err != nil {
|
||||
t.Skipf("造不出只有音轨的文件,跳过:%v %s", err, out)
|
||||
}
|
||||
|
||||
jobFinish("")
|
||||
err := runMergeExclusive(func() error {
|
||||
_, e := runTranscode(transcodeJobReq{
|
||||
Dir: filepath.Base(recordSaveDir()), Name: filepath.Base(src),
|
||||
}, mergeSaveDir(), 1, 1)
|
||||
return e
|
||||
})
|
||||
jobFinish(jobErrText(err))
|
||||
if err == nil {
|
||||
t.Fatal("只有音轨的文件不该转得下去")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "没有视频轨") {
|
||||
t.Errorf("报错要说明是没有视频轨,实际:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 小工具 ====================
|
||||
|
||||
func has(args []string, v string) bool {
|
||||
for _, a := range args {
|
||||
if a == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ==================== 源文件定位 ====================
|
||||
|
||||
// 填了路径就按路径找,录播/成片文件夹之外的文件也要能转;没填才回落到 dir + name
|
||||
func TestResolveTranscodeJobByPath(t *testing.T) {
|
||||
setupMergeTest(t)
|
||||
outside := t.TempDir()
|
||||
video := filepath.Join(outside, "别处的录播.mp4")
|
||||
writeTestFile(t, video, "x")
|
||||
|
||||
// 绝对路径要认,从资源管理器复制来的带引号路径也要认
|
||||
for _, in := range []string{video, `"` + video + `"`} {
|
||||
got, err := resolveTranscodeJob(transcodeJobReq{Path: in})
|
||||
if err != nil {
|
||||
t.Fatalf("%q 应当能解析:%v", in, err)
|
||||
}
|
||||
if normalizePath(got) != normalizePath(video) {
|
||||
t.Errorf("%q 解析成了 %q,想要 %q", in, got, video)
|
||||
}
|
||||
}
|
||||
|
||||
// 不填路径时还是按录播目录 + 文件名找,老写法不能坏
|
||||
rec := filepath.Join(recordSaveDir(), "录播里的.mp4")
|
||||
writeTestFile(t, rec, "x")
|
||||
got, err := resolveTranscodeJob(transcodeJobReq{Dir: "record", Name: "录播里的.mp4"})
|
||||
if err != nil {
|
||||
t.Fatalf("dir + name 应当能解析:%v", err)
|
||||
}
|
||||
if normalizePath(got) != normalizePath(rec) {
|
||||
t.Errorf("dir + name 解析成了 %q,想要 %q", got, rec)
|
||||
}
|
||||
}
|
||||
|
||||
// 路径这条能指向磁盘上任何文件,格式白名单必须照样过一遍
|
||||
func TestResolveTranscodeJobRejectsBadPath(t *testing.T) {
|
||||
setupMergeTest(t)
|
||||
outside := t.TempDir()
|
||||
danmu := filepath.Join(outside, "某场.jsonl")
|
||||
writeTestFile(t, danmu, "{}")
|
||||
sub := filepath.Join(outside, "子文件夹")
|
||||
if err := os.MkdirAll(sub, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bad := map[string]transcodeJobReq{
|
||||
"不是视频后缀": {Path: danmu},
|
||||
"指向文件夹": {Path: sub},
|
||||
"路径不存在": {Path: filepath.Join(outside, "没有这个文件.mp4")},
|
||||
"路径和名字都没填": {},
|
||||
}
|
||||
for name, j := range bad {
|
||||
if _, err := resolveTranscodeJob(j); err == nil {
|
||||
t.Errorf("%s:应当被挡住,实际通过了", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 探测接口要认 {path} —— 这是「转码任意文件」的实际入口,只测 resolve 不足以证明接线对了。
|
||||
// 这里故意用一个不是视频的文件:探测拿不到流信息,但接口仍要 200 并把路径原样回显
|
||||
func TestTranscodeProbeHandlerAcceptsPath(t *testing.T) {
|
||||
setupMergeTest(t)
|
||||
outside := t.TempDir()
|
||||
video := filepath.Join(outside, "别处.mp4")
|
||||
writeTestFile(t, video, "not a real video")
|
||||
|
||||
body := `{"files":[{"path":` + strconv.Quote(video) + `}]}`
|
||||
req := httptest.NewRequest("POST", "/api/transcode/probe", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
transcodeProbeHandler(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("按路径探测应当 200,实际 %d:%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Files []struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
} `json:"files"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(resp.Files) != 1 || resp.Files[0].Name != "别处.mp4" {
|
||||
t.Fatalf("探测结果不对:%+v", resp.Files)
|
||||
}
|
||||
if normalizePath(resp.Files[0].Path) != normalizePath(video) {
|
||||
t.Errorf("回显的 path 是 %q,想要 %q", resp.Files[0].Path, video)
|
||||
}
|
||||
}
|
||||
|
||||
func indexOf(args []string, v string) int {
|
||||
for i, a := range args {
|
||||
if a == v {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// 参数里是否连续出现了这两个值
|
||||
func containsSeq(args []string, a, b string) bool {
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == a && args[i+1] == b {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 取 -vf 后面的值
|
||||
func valueAfter(args []string, key string) string {
|
||||
for i, a := range args {
|
||||
if a == key && i+1 < len(args) {
|
||||
return args[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 单独构造一次参数给上面的用例用(避免重复拼 src)
|
||||
func transcodeArgsArgs(ff string, src mediaInfo) []string {
|
||||
args, _, _ := transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Scale: 2160}, src)
|
||||
return args
|
||||
}
|
||||
@@ -107,17 +107,22 @@ func checkErr(err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 后台任务真正怎么起。单独抽成一个变量是为了让测试能换成「可等待」的版本:
|
||||
// fire-and-forget 的 goroutine 漏到测试结束之后,会在下一个测试改全局配置时读它们,
|
||||
// -race 会判成数据竞争
|
||||
var runBackground = func(f func()) { go f() }
|
||||
|
||||
// 后台任务的统一入口:兜底 recover,避免单个任务的 panic 终止整个进程
|
||||
// name 用于日志里指出是哪个任务出错,f 里可以继续做自己的 recover
|
||||
func safeGo(name string, f func()) {
|
||||
go func() {
|
||||
runBackground(func() {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
lPrintErrf("%s 发生错误并已恢复,其它录制任务不受影响:%v", name, err)
|
||||
}
|
||||
}()
|
||||
f()
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// 不需要重试的错误:再试一遍也只是重复同样的失败(比如头像本身就超出体积/尺寸上限),
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 造 n 条弹幕:从 start 秒开始,每条间隔 step
|
||||
func mkDanmuForFit(start, step float64, n int) []danmuRecord {
|
||||
out := make([]danmuRecord, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, danmuRecord{
|
||||
Time: start + float64(i)*step, UID: int64(100 + i), Name: "甲",
|
||||
Content: fmt.Sprintf("第 %d 条", i), Width: 100,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 正常场次:时间轴一点都不该动
|
||||
func TestFitDanmuToVideoLeavesNormalAlone(t *testing.T) {
|
||||
records := mkDanmuForFit(1, 1, 10) // 1 ~ 10 秒
|
||||
got, off := fitDanmuToVideo(records, 0, 60)
|
||||
if len(got) != 10 || off != 0 {
|
||||
t.Fatalf("正常场次不该动:留 %d 条、偏移 %v", len(got), off)
|
||||
}
|
||||
}
|
||||
|
||||
// 弹幕确实录得比视频久(视频中途断过):只丢尾部超出的,时间轴不动
|
||||
func TestFitDanmuToVideoDropsTailOnly(t *testing.T) {
|
||||
records := mkDanmuForFit(1, 5, 20) // 1 ~ 96 秒,视频只有 50 秒
|
||||
got, off := fitDanmuToVideo(records, 0, 50)
|
||||
if off != 0 {
|
||||
t.Fatalf("只该丢尾部,偏移应保持 0,实际 %v", off)
|
||||
}
|
||||
if len(got) == 0 || len(got) == len(records) {
|
||||
t.Fatalf("应该留一部分、丢一部分,实际留 %d 条", len(got))
|
||||
}
|
||||
if last := got[len(got)-1].Time; last >= 50 {
|
||||
t.Fatalf("留下的最后一条应在视频内,实际 %v", last)
|
||||
}
|
||||
}
|
||||
|
||||
// 时间基准整体偏移的 .ass(时间相对程序启动、不是录制开始):按第一条弹幕对齐视频起点
|
||||
func TestFitDanmuToVideoAlignsShiftedTimeBase(t *testing.T) {
|
||||
// 现实素材的形状:7849.92 ~ 13286.59 秒 / 视频 5456.883 秒
|
||||
records := mkDanmuForFit(7849.92, 5.7, 955)
|
||||
got, off := fitDanmuToVideo(records, 0, 5456.883)
|
||||
if off != -7849.92 {
|
||||
t.Fatalf("偏移应为 -7849.92,实际 %v", off)
|
||||
}
|
||||
if len(got) != len(records) {
|
||||
t.Fatalf("跨度装得进视频,不该丢弹幕,实际留 %d / %d 条", len(got), len(records))
|
||||
}
|
||||
if first := got[0].Time + off; first != 0 {
|
||||
t.Fatalf("第一条应落在视频起点,实际 %v", first)
|
||||
}
|
||||
if end := got[len(got)-1].Time + off; end >= 5456.883 {
|
||||
t.Fatalf("最后一条应在视频内,实际 %v", end)
|
||||
}
|
||||
}
|
||||
|
||||
// 边界:跨度刚好比视频短一点点,对齐后一条都不该丢
|
||||
func TestFitDanmuToVideoAlignJustFits(t *testing.T) {
|
||||
records := mkDanmuForFit(1000, 1, 1000) // 1000 ~ 1999 秒,跨度 999 秒
|
||||
got, off := fitDanmuToVideo(records, 0, 1000)
|
||||
if off != -1000 {
|
||||
t.Fatalf("偏移应为 -1000,实际 %v", off)
|
||||
}
|
||||
if len(got) != len(records) {
|
||||
t.Fatalf("跨度装得进视频,不该丢弹幕,实际留 %d / %d 条", len(got), len(records))
|
||||
}
|
||||
if end := got[len(got)-1].Time + off; end >= 1000 {
|
||||
t.Fatalf("最后一条应在视频内,实际 %v", end)
|
||||
}
|
||||
}
|
||||
|
||||
// 弹幕跨度本身就装不进视频:平移救不回来,只能按原行为一条不留
|
||||
func TestFitDanmuToVideoGivesUpWhenSpanTooLong(t *testing.T) {
|
||||
records := mkDanmuForFit(7849.92, 10, 100) // 跨度 990 秒 > 视频 500 秒
|
||||
got, off := fitDanmuToVideo(records, 0, 500)
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("跨度装不下时应一条不留,实际 %d 条", len(got))
|
||||
}
|
||||
if off != 0 {
|
||||
t.Fatalf("放弃时不该改偏移,实际 %v", off)
|
||||
}
|
||||
}
|
||||
|
||||
// 偏移把弹幕整体推到视频之前时保持原行为(这些条目不算「超出时长」,由渲染那边处理)
|
||||
func TestFitDanmuToVideoKeepsAllBeforeVideoBehaviour(t *testing.T) {
|
||||
records := mkDanmuForFit(100, 1, 10)
|
||||
got, off := fitDanmuToVideo(records, -1000, 500)
|
||||
if len(got) != len(records) || off != -1000 {
|
||||
t.Fatalf("整体在视频之前时不该动:留 %d 条、偏移 %v", len(got), off)
|
||||
}
|
||||
}
|
||||
|
||||
// 用户实测的那种场次:.ass 时间基准比视频晚一大截,合并要能成功并且真的画上弹幕
|
||||
func TestManualMergeFromShiftedAssEndToEnd(t *testing.T) {
|
||||
dir := setupMergeTest(t)
|
||||
ffmpeg := getFFmpeg()
|
||||
if ffmpeg == "" {
|
||||
t.Skip("没有 ffmpeg")
|
||||
}
|
||||
|
||||
name := "2026-09-25 02-00-00 测试主播 时间基准偏移"
|
||||
video := filepath.Join(recordSaveDir(), name+".mp4")
|
||||
cmd := exec.Command(ffmpeg, "-v", "error", "-y",
|
||||
"-f", "lavfi", "-i", "color=c=black:size=640x360:rate=25", "-t", "8",
|
||||
"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", video)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("生成测试视频失败:%v\n%s", err, out)
|
||||
}
|
||||
|
||||
// 弹幕时间整体比录制起点晚 2 小时(模拟「程序先开着、直播中途才开始录」)
|
||||
ass := fmt.Sprintf(`[Script Info]
|
||||
Title: %s
|
||||
ScriptType: v4.00+
|
||||
Collisions: Normal
|
||||
PlayResX: 640
|
||||
PlayResY: 360
|
||||
|
||||
[V4+ Styles]
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
Style: Danmu,Microsoft YaHei,40,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,20,20,2,0
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,2:00:01.00,2:00:11.00,Danmu,甲(101),20,20,2,,{\move(400,40,-40,40)}第一条弹幕
|
||||
Dialogue: 0,2:00:03.00,2:00:13.00,Danmu,乙(202),20,20,2,,{\move(400,80,-40,80)}第二条弹幕
|
||||
Dialogue: 0,2:00:05.00,2:00:15.00,Danmu,甲(101),20,20,2,,{\move(400,120,-40,120)}第三条弹幕
|
||||
`, name)
|
||||
assPath := writeTestFile(t, filepath.Join(recordSaveDir(), name+".ass"), ass)
|
||||
|
||||
if _, err := prepareDanmuSource(assPath, true); err != nil {
|
||||
t.Fatalf("还原弹幕失败:%v", err)
|
||||
}
|
||||
plan, err := buildMergePlan(mergeRequest{Videos: []string{video}, Danmu: assPath})
|
||||
if err != nil {
|
||||
t.Fatalf("检查失败:%v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
oldOut := logger.Writer()
|
||||
logger.SetOutput(&buf)
|
||||
err = execManualMerge(plan)
|
||||
logger.SetOutput(oldOut)
|
||||
if err != nil {
|
||||
t.Fatalf("时间基准偏移的场次应当能合出来,实际失败:%v\n日志:\n%s", err, buf.String())
|
||||
}
|
||||
log := buf.String()
|
||||
if !strings.Contains(log, "按「第一条弹幕对齐视频起点」") {
|
||||
t.Fatalf("日志里应写明自动对齐:\n%s", log)
|
||||
}
|
||||
if !strings.Contains(log, "对齐后弹幕覆盖") {
|
||||
t.Fatalf("日志里应写明对齐后的覆盖范围:\n%s", log)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(plan.out); err != nil {
|
||||
t.Fatalf("没有生成成片:%v", err)
|
||||
}
|
||||
|
||||
// 成片里确实画了弹幕:同一时刻抽帧,有字的帧 PNG 一定比纯黑源片大
|
||||
shot := func(src, tag string) int {
|
||||
out := filepath.Join(dir, "shot_"+tag+".png")
|
||||
c := exec.Command(ffmpeg, "-v", "error", "-y", "-ss", "3", "-i", src,
|
||||
"-frames:v", "1", "-update", "1", out)
|
||||
if o, err := c.CombinedOutput(); err != nil {
|
||||
t.Fatalf("抽帧失败:%v\n%s", err, o)
|
||||
}
|
||||
fi, err := os.Stat(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return int(fi.Size())
|
||||
}
|
||||
srcSize, outSize := shot(video, "src"), shot(plan.out, "out")
|
||||
if outSize <= srcSize {
|
||||
t.Fatalf("成片的帧没有比纯黑源片大,弹幕很可能没画上:源 %d 字节、成片 %d 字节", srcSize, outSize)
|
||||
}
|
||||
t.Logf("抽帧大小:源 %d 字节、成片 %d 字节", srcSize, outSize)
|
||||
}
|
||||
|
||||
// 同一次 .ass 还原只该在日志里出现一条(页面的「检查」和合并都会解析一遍)
|
||||
func TestPrepareDanmuSourceLogsRestoreOnce(t *testing.T) {
|
||||
setupMergeTest(t)
|
||||
path := writeTestFile(t,
|
||||
filepath.Join(recordSaveDir(), "2026-09-25 03-00-00 测试主播 标题.ass"), plainAssFixture)
|
||||
|
||||
var buf bytes.Buffer
|
||||
oldOut := logger.Writer()
|
||||
logger.SetOutput(&buf)
|
||||
defer logger.SetOutput(oldOut)
|
||||
|
||||
// 「检查」先解析一遍(不落盘),合并再解析一遍并落盘
|
||||
if _, err := prepareDanmuSource(path, false); err != nil {
|
||||
t.Fatalf("检查失败:%v", err)
|
||||
}
|
||||
if _, err := prepareDanmuSource(path, true); err != nil {
|
||||
t.Fatalf("还原失败:%v", err)
|
||||
}
|
||||
|
||||
log := buf.String()
|
||||
if n := strings.Count(log, "还原出"); n != 1 {
|
||||
t.Fatalf("「还原出」应只出现 1 次,实际 %d 次:\n%s", n, log)
|
||||
}
|
||||
if n := strings.Count(log, "还原成"); n != 0 {
|
||||
t.Fatalf("不该再有「还原成」这条日志,实际 %d 次:\n%s", n, log)
|
||||
}
|
||||
if !strings.Contains(log, filepath.Base(path)) {
|
||||
t.Fatalf("日志里应写明是哪个文件:\n%s", log)
|
||||
}
|
||||
}
|
||||
+3
-4
@@ -349,10 +349,9 @@ func TestWebuiSettingFieldsMatchInputs(t *testing.T) {
|
||||
"pickHidden": "路径选择弹窗的内部状态",
|
||||
"pickPath": "路径选择弹窗的内部状态",
|
||||
"mrgVideoPick": "手动合并:视频列表,随合并请求提交",
|
||||
"mrgVideos": "手动合并:视频路径多行框,随合并请求提交",
|
||||
"mrgDanmu": "手动合并:弹幕文件,随合并请求提交",
|
||||
"mrgOffset": "手动合并:偏移秒数,随合并请求提交",
|
||||
"mrgOut": "手动合并:成片名字,随合并请求提交",
|
||||
"tcFilePick": "转码:待选文件列表,随转码请求提交",
|
||||
"tcPaths": "转码:手填的文件路径列表,随转码请求提交",
|
||||
"tcOutDir": "转码:输出目录,随转码请求提交",
|
||||
}
|
||||
for id := range ids {
|
||||
if settings[id] {
|
||||
|
||||
Reference in New Issue
Block a user