Compare commits
27
Commits
9c94a72c4d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dadd3f7cea | ||
|
|
40f4f36aa6 | ||
|
|
2b1705a970 | ||
|
|
7ab80ec429 | ||
|
|
3c17503a21 | ||
|
|
a409c5b4dd | ||
|
|
3e16e78003 | ||
|
|
6f3c8096b4 | ||
|
|
3eb4aa3102 | ||
|
|
a2b7185a7b | ||
|
|
a24425e16d | ||
|
|
9684346067 | ||
|
|
8e82aa54c6 | ||
|
|
9b9b3fa2cd | ||
|
|
4e8471deea | ||
|
|
c55fe3539d | ||
|
|
c26eb4c648 | ||
|
|
7dd2487cb8 | ||
|
|
cbe5dfa982 | ||
|
|
aa47dd342d | ||
|
|
237fffa8fe | ||
|
|
7b853c1a22 | ||
|
|
348e3bd675 | ||
|
|
eda83dd1e3 | ||
|
|
a891094f29 | ||
|
|
e8b455dfe8 | ||
|
|
e44dfa3a4a |
@@ -9,6 +9,8 @@ acfunlive
|
|||||||
/bin
|
/bin
|
||||||
/merged
|
/merged
|
||||||
/record
|
/record
|
||||||
|
# 头像缓存放在程序所在目录,程序正好跑在仓库里时会落在这里,别提交进去
|
||||||
|
/avatar_cache
|
||||||
|
|
||||||
# Binaries for programs and plugins
|
# Binaries for programs and plugins
|
||||||
*.exe
|
*.exe
|
||||||
|
|||||||
@@ -70,19 +70,22 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
|||||||
"uid": 23682490, // 主播的 uid
|
"uid": 23682490, // 主播的 uid
|
||||||
"danmu": true, // 是否录制弹幕
|
"danmu": true, // 是否录制弹幕
|
||||||
"record": true, // 是否录制直播视频
|
"record": true, // 是否录制直播视频
|
||||||
"bitrate": 0 // 录制的直播源最高码率(Kbps),0 表示码率最高的直播源
|
"qualityPref": "second", // 档位偏好:top(最高档)/ second(第二高)/ lowest(最低档)/ custom(自定义)
|
||||||
|
"bitrate": 0, // 只在 qualityPref 为 custom 时有意义:不超过这个码率(Kbps)的最高档
|
||||||
|
"qualities": [] // 开播时读到的码率档位列表,缓存下来让没开播时也能显示对应关系
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"directory": "", // 录制文件保存的文件夹,留空是程序运行时的 -record 文件夹
|
"directory": "", // 录播和弹幕文件的保存根目录,录制时就直接写在这里;留空用运行时的 -record 文件夹
|
||||||
"webPort": 51880, // web UI 的端口,为 0 时不启动 web UI
|
"webPort": 51880, // web UI 的端口,为 0 时不启动 web UI
|
||||||
|
|
||||||
"autoMerge": false, // 直播结束后自动把弹幕层合并进录播
|
"autoMerge": false, // 直播结束后自动把弹幕层合并进录播
|
||||||
"mergeDelVideo": false, // 合并完成后删除源视频
|
"mergeDelVideo": false, // 合并完成后删除源视频
|
||||||
"mergeDelDanmu": false, // 合并完成后删除弹幕文件(ass、nick.ass、jsonl)
|
"mergeDelDanmu": false, // 合并完成后删除弹幕文件(ass、nick.ass、jsonl)
|
||||||
"mergeOutputDir": "", // 合并完成后成片移动到该文件夹,留空则不移动
|
"mergeOutputDir": "", // 合并完成后成片移动到该文件夹,留空则不移动
|
||||||
"mergeBitrate": 0, // 合并成片的目标码率(Kbps),0 表示使用默认质量
|
"mergeBitrate": 0, // 合并成片的目标码率(Kbps),0 表示按「源片码率 + 500」自动计算
|
||||||
"danmuFontSize": 0, // 合并时弹幕文字的字号(像素),0 表示跟随弹幕文件;头像随字号等比缩放
|
"danmuFontSize": 0, // 合并时弹幕文字的字号(像素),0 表示按画面分辨率自动(短边 × 5.56%,横屏取高、竖屏取宽);头像随字号等比缩放
|
||||||
"defaultBitrate": 0, // 新添加主播的默认 bitrate,0 表示码率最高的直播源
|
"defaultQualityPref": "second", // 新添加主播的默认档位偏好,取值同上面的 qualityPref
|
||||||
|
"defaultBitrate": 0, // 只在上面为 custom 时有意义:默认的自定义码率上限(Kbps)
|
||||||
|
|
||||||
"webExternal": false, // 是否允许外网访问 web UI,false 时只监听本机 127.0.0.1
|
"webExternal": false, // 是否允许外网访问 web UI,false 时只监听本机 127.0.0.1
|
||||||
"authToken": "…", // web UI 管理接口的访问口令,首次启动自动生成
|
"authToken": "…", // web UI 管理接口的访问口令,首次启动自动生成
|
||||||
@@ -94,7 +97,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
|||||||
"notifyLiveOn": false, // 开播时推送
|
"notifyLiveOn": false, // 开播时推送
|
||||||
"notifyLiveOff": false, // 下播时推送
|
"notifyLiveOff": false, // 下播时推送
|
||||||
"notifyRecordStart": false, // 开始录制直播视频时推送
|
"notifyRecordStart": false, // 开始录制直播视频时推送
|
||||||
"notifyMergeDone": false, // 自动合并完成时推送
|
"notifyMergeDone": false, // 合并完成时推送(自动合并和手动合并都会推)
|
||||||
|
|
||||||
"meowEnabled": false, // MeoW 推送的渠道开关
|
"meowEnabled": false, // MeoW 推送的渠道开关
|
||||||
"meowNickname": "", // MeoW 昵称,推送地址里的路径参数
|
"meowNickname": "", // MeoW 昵称,推送地址里的路径参数
|
||||||
@@ -109,11 +112,22 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`uids`也兼容旧的纯数字数组格式,如`"uids": [23682490, 12345]`,此时弹幕和录播都默认开启、码率取最高。
|
`uids`也兼容旧的纯数字数组格式,如`"uids": [23682490, 12345]`,此时弹幕和录播都默认开启、码率按实际档位取第二档。
|
||||||
|
|
||||||
`bitrate`为 0 时录制码率最高的直播源,设置为其他数字时录制码率不超过该值的最高档(如 2000 大约是超清档),可以在 web UI 的主播列表里随时修改。录制始终是原画质直接保存(`-c copy`),不会重新编码,所以`bitrate`只能在这几个档位里挑选。
|
`qualityPref`是**按档位里的第几档**来选的,不是写死某个码率 —— 因为每场直播的档位表不一定一样(不一定都有`蓝光 8M`):
|
||||||
|
|
||||||
录播原片和弹幕文件(`.ass`、`.nick.ass`、`.jsonl`)保存在保存目录下的`recordSubdir`子文件夹里(默认`record`),两者同名配对才能自动合并;合并后的成片保存在`mergeSubdir`子文件夹里(默认`merged`),留空则用默认值,两个名字不能相同。如果设置了`mergeOutputDir`,成片优先移动到那里。
|
| 取值 | 页面上的选项 | 含义 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `top` | 最高档 | 这次能读到的最高档 |
|
||||||
|
| `second` | 第二高(默认) | 按实际档位排序自上往下第二高,只有一档时用它 |
|
||||||
|
| `lowest` | 最低档 | 这次能读到的最低档 |
|
||||||
|
| `custom` | 自定义 | 取不超过`bitrate`(Kbps)的最高档,都比它高时退回最低档 |
|
||||||
|
|
||||||
|
四种偏好**永远都在下拉里**,所以还没开播、连档位表都没读到的时候也能先设好;读到档位表以后,每个选项后面会标出它现在对应哪个档位(例如`第二高(蓝光 4M)`、`自定义(≤2500K = 高清)`),选完立即生效,正在录制时换档会自动重录。选`自定义`时会出现一个码率框,初始值就是当前实际在用的那一档,填完回车即生效。
|
||||||
|
|
||||||
|
档位表要开播后读直播源列表才知道(写在`config.json`的`qualities`里缓存)。没读到过的行,选项后面不会带档位名,旁边的`档位`按钮可以去直播间接一次(主播没开播时会失败并提示,服务端本身要重试三次、约 5 秒,所以点下去会先给一句`正在读取码率档位…`)。读档位的时机是:录制开始取流时、页面上点`档位`按钮、以及发现主播在直播但还没有档位缓存时自动读一次(同一个主播 10 秒内只读一次)。录制始终是原画质直接保存(`-c copy`),不会重新编码,所以只能在这几个档位里挑。
|
||||||
|
|
||||||
|
录播原片和弹幕文件(`.ass`、`.nick.ass`、`.jsonl`)保存在保存目录下的`recordSubdir`子文件夹里(默认`record`),**录制过程中 ffmpeg 就直接写在这里**,不会再先落到别处中转;两者同名配对才能自动合并。保存目录的优先级是:`directory` > 命令行`-record`指定的文件夹 > 程序所在目录。合并后的成片保存在`mergeSubdir`子文件夹里(默认`merged`),留空则用默认值,两个名字不能相同。如果设置了`mergeOutputDir`,成片优先移动到那里。
|
||||||
|
|
||||||
合成**先在录播文件夹里做完,写完了才落到成片文件夹**(同一个盘时是直接改名,几乎瞬时;跨盘会自动拷贝):成片文件夹是网络盘、外接盘或者中途断线时,不会在里面留下一个写到一半、看起来却像正常成片的文件。合成失败或中途强杀程序时,临时文件(`acfunlive_chunk_*`、`*.acfunlive.part`)不会出现在文件管理的列表里,程序下次启动会自动清掉。
|
合成**先在录播文件夹里做完,写完了才落到成片文件夹**(同一个盘时是直接改名,几乎瞬时;跨盘会自动拷贝):成片文件夹是网络盘、外接盘或者中途断线时,不会在里面留下一个写到一半、看起来却像正常成片的文件。合成失败或中途强杀程序时,临时文件(`acfunlive_chunk_*`、`*.acfunlive.part`)不会出现在文件管理的列表里,程序下次启动会自动清掉。
|
||||||
|
|
||||||
@@ -136,7 +150,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
|||||||
|
|
||||||
`authToken`是管理接口的访问口令,首次启动时自动生成(12 字节随机数的十六进制)并打印在运行日志里,忘记时可以打开`config.json`查看,也能在 web UI 的`设置`分区查看、复制和更换。**所有`/api/`接口都必须带口令**,页面本身可以匿名打开,打开后会要求输入口令并保存在浏览器本地。口令可以用三种方式提供:`X-Auth-Token`请求头、`Authorization: Bearer <口令>`、或者`?token=<口令>`查询参数。口令可以用任意字符,包括中文。注意 HTTP 请求头只接受 ISO-8859-1,浏览器遇到中文口令会直接拒绝发请求,所以页面发给服务端的`X-Auth-Token`是百分号编码过的口令,服务端原样和解码后的都认;用 curl 或脚本调接口时按原样填即可,不用自己编码。同一来源连续输错时每次会增加最多 2 秒延迟,避免被暴力尝试。手工把`config.json`里的`authToken`清空保存后,程序会自动重新生成一个,不会把自己锁在外面。
|
`authToken`是管理接口的访问口令,首次启动时自动生成(12 字节随机数的十六进制)并打印在运行日志里,忘记时可以打开`config.json`查看,也能在 web UI 的`设置`分区查看、复制和更换。**所有`/api/`接口都必须带口令**,页面本身可以匿名打开,打开后会要求输入口令并保存在浏览器本地。口令可以用三种方式提供:`X-Auth-Token`请求头、`Authorization: Bearer <口令>`、或者`?token=<口令>`查询参数。口令可以用任意字符,包括中文。注意 HTTP 请求头只接受 ISO-8859-1,浏览器遇到中文口令会直接拒绝发请求,所以页面发给服务端的`X-Auth-Token`是百分号编码过的口令,服务端原样和解码后的都认;用 curl 或脚本调接口时按原样填即可,不用自己编码。同一来源连续输错时每次会增加最多 2 秒延迟,避免被暴力尝试。手工把`config.json`里的`authToken`清空保存后,程序会自动重新生成一个,不会把自己锁在外面。
|
||||||
|
|
||||||
`defaultBitrate`是下载码率的默认值:在 web UI 里新增主播、且不单独指定码率时使用它,也可以在 web UI 的`录制`分区随时修改,只对新添加的主播生效,已有主播保持各自的值。
|
`defaultQualityPref`/`defaultBitrate`是新主播的默认档位:在 web UI 里新增主播、且没有单独指定档位时用它,`录制`分区可以随时改,只对新添加的主播生效,已有主播保持各自选的。默认是`第二高`,也就是按主播**实际能用的档位**自上往下取第二档(比如有`蓝光 8M / 蓝光 4M / 高清`就录`蓝光 4M`),只有一档时就用那一档;档位表要开播后才读得到,读到以后会记进`config.json`,没开播时页面也能显示和修改。
|
||||||
|
|
||||||
`ffmpegPath`用来指定 ffmpeg 的位置,可以填可执行文件本身(如`D:\ffmpeg\bin\ffmpeg.exe`)或它所在的文件夹,留空时按「PATH → 程序所在文件夹 → 程序所在文件夹的 bin、ffmpeg、ffmpeg/bin 子目录」的顺序自动查找。填相对路径时按程序所在目录解析,不会因为合并时会切换工作目录而失效(这是`ffmpeg.exe`这种只写文件名的写法能正常工作的原因)。录播和弹幕合并都依赖 ffmpeg,找不到时启动日志里会有明确提示。
|
`ffmpegPath`用来指定 ffmpeg 的位置,可以填可执行文件本身(如`D:\ffmpeg\bin\ffmpeg.exe`)或它所在的文件夹,留空时按「PATH → 程序所在文件夹 → 程序所在文件夹的 bin、ffmpeg、ffmpeg/bin 子目录」的顺序自动查找。填相对路径时按程序所在目录解析,不会因为合并时会切换工作目录而失效(这是`ffmpeg.exe`这种只写文件名的写法能正常工作的原因)。录播和弹幕合并都依赖 ffmpeg,找不到时启动日志里会有明确提示。
|
||||||
|
|
||||||
@@ -146,19 +160,33 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
|||||||
|
|
||||||
运行时可用参数`-record`指定录制文件保存的文件夹,用`-config`指定配置文件所在文件夹。
|
运行时可用参数`-record`指定录制文件保存的文件夹,用`-config`指定配置文件所在文件夹。
|
||||||
|
|
||||||
web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`),可以在网页里增删主播、切换每个主播的弹幕和录播开关、修改码率上限、修改保存目录和自动合并设置。
|
web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`),可以在网页里增删主播、切换每个主播的弹幕和录播开关、选择录制的码率档位、修改保存目录和自动合并设置。
|
||||||
|
|
||||||
页面按用途分成六个分区,顶部一条导航切换,地址栏会跟着带上`#分区名`,所以刷新、收藏、直接把链接发给别人都能落到同一个分区:
|
页面按用途分成七个分区,顶部一条导航切换,地址栏会跟着带上`#分区名`,所以刷新、收藏、直接把链接发给别人都能落到同一个分区:
|
||||||
|
|
||||||
| 分区 | 内容 |
|
| 分区 | 内容 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 监控 | 添加主播、主播列表(默认打开的分区),以及运行日志 |
|
| 监控 | 当前转换任务的进度卡片、添加主播、主播列表(默认打开的分区),以及运行日志 |
|
||||||
| 录制 | 保存目录和子文件夹、录播格式、默认码率上限、ffmpeg 路径 |
|
| 录制 | 保存目录和子文件夹、录播格式、默认码率上限、ffmpeg 路径 |
|
||||||
| 合并 | 自动合并开关、成片输出目录和合成码率、手动合并 |
|
| 合并 | 自动合并开关、成片输出目录和合成码率、手动合并(可一次排多个任务,每个任务单独配参数) |
|
||||||
|
| 转码 | 把录播、成片或磁盘上任意位置的视频重新压一遍:每个文件单独配置编码器、码率、帧率、分辨率、音频,并预估产物大小 |
|
||||||
| 通知 | 推送哪些事件,以及 QQ 机器人 / MeoW 两个渠道的配置和测试连通 |
|
| 通知 | 推送哪些事件,以及 QQ 机器人 / MeoW 两个渠道的配置和测试连通 |
|
||||||
| 文件 | 磁盘剩余容量、录播和成片的文件列表 |
|
| 文件 | 磁盘剩余容量、录播和成片的文件列表 |
|
||||||
| 设置 | 是否允许外网访问、管理口令的查看与更换 |
|
| 设置 | 是否允许外网访问、管理口令的查看与更换 |
|
||||||
|
|
||||||
|
### 转换任务进度
|
||||||
|
|
||||||
|
自动合并、手动合并和转码跑起来之后,`监控`分区顶部会出现一张`转换任务`卡片,页面最顶上还有一条很细的全局进度条,切到哪个分区都能看到。卡片里给的是:
|
||||||
|
|
||||||
|
- **阶段步骤条**:合并是准备 → 分段拼接 → 获取头像 → 画面渲染 → 拼接与落盘,转码是准备 → 转码 → 落盘,走过的打勾、当前的高亮(暂停时变红);一次排了多个任务时标题上还会标`第 n/N 个`;
|
||||||
|
- **总进度条和百分比**:渲染阶段按「已渲染时长 ÷ 视频总时长」算,其余阶段按各自的完成比例折算后加权;
|
||||||
|
- **实时指标**:渲染倍速、帧率、已处理时长 / 总时长、已用时间、已写出多少;
|
||||||
|
- **预计剩余时间**:渲染阶段用「剩余时长 ÷ 当前倍速」,别的阶段按已用时间线性外推;
|
||||||
|
- **体积对比条**:源片大小和预计成片大小并排显示两段,预计值先按目标码率估,渲染过 5% 之后换成按已写出字节外推(这时候标注会从`按目标码率估算`变成`按已渲染部分推算`);
|
||||||
|
- **渲染速度折线**:最近两分钟、每秒一个采样点,能看出硬件编码和 CPU 渲染有没有被别的任务拖慢。
|
||||||
|
|
||||||
|
任务跑着的时候可以直接**暂停**和**停止**:暂停会挂起 ffmpeg 进程(Windows 用`NtSuspendProcess`、Linux 用`SIGSTOP`),CPU 占用归零、进度冻结,点`继续`从原地接着跑;不在 ffmpeg 环节(比如正在下载头像)时,暂停和停止会在下一个检查点生效。停止会中断这次合并并**丢弃已经渲染的部分**——成片目录不会留下半成品,录播目录里的分块临时文件也会一并清掉,所以需要重新跑一次。
|
||||||
|
|
||||||
开关和下拉框改完立即保存;文本框(目录、地址、口令这些)填完按回车或点右上角的`保存设置`才生效,这一条在每个有文本框的分区里也写了提示。
|
开关和下拉框改完立即保存;文本框(目录、地址、口令这些)填完按回车或点右上角的`保存设置`才生效,这一条在每个有文本框的分区里也写了提示。
|
||||||
|
|
||||||
目录和文件两条路都行:直接在框里填,或者点框右边的`浏览`逐级点选。弹窗里可以一路点进子文件夹、点`C:`/`D:`这类按钮(Linux 上是`/`、`/home`、`/mnt` 这些常用位置)换根位置,地址栏本身也能粘贴路径回车跳转,随时在点选和手填之间切换;选好点`选用这个文件夹`填回设置框,仍然是按回车或点保存才生效。填的是文件时(比如 ffmpeg 路径)列出的是文件,双击即选中并填入。默认不显示以点开头的隐藏项,需要时勾上`显示隐藏项`。
|
目录和文件两条路都行:直接在框里填,或者点框右边的`浏览`逐级点选。弹窗里可以一路点进子文件夹、点`C:`/`D:`这类按钮(Linux 上是`/`、`/home`、`/mnt` 这些常用位置)换根位置,地址栏本身也能粘贴路径回车跳转,随时在点选和手填之间切换;选好点`选用这个文件夹`填回设置框,仍然是按回车或点保存才生效。填的是文件时(比如 ffmpeg 路径)列出的是文件,双击即选中并填入。默认不显示以点开头的隐藏项,需要时勾上`显示隐藏项`。
|
||||||
@@ -173,7 +201,7 @@ web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`),
|
|||||||
|
|
||||||
命令行模式运行时可以输入命令控制本程序,输入`help`查看全部命令的解释,输入`quit`退出程序。
|
命令行模式运行时可以输入命令控制本程序,输入`help`查看全部命令的解释,输入`quit`退出程序。
|
||||||
|
|
||||||
录制直播视频时用`ffmpeg -c copy`直接保存直播源,不转码,分辨率和码率跟随直播源,默认取码率最高的源。
|
录制直播视频时用`ffmpeg -c copy`直接保存直播源,不转码,分辨率和码率跟随直播源,档位就是上面说的那个(默认选第二档,而不是吃满最高档)。
|
||||||
|
|
||||||
录制弹幕时会同时生成三个文件:
|
录制弹幕时会同时生成三个文件:
|
||||||
|
|
||||||
@@ -200,7 +228,7 @@ acfunlive -danmuburn xxx.jsonl -video xxx.flv -ffmpeg D:\tools\ffmpeg.exe
|
|||||||
|
|
||||||
`-danmuburn`的弹幕文件支持`.jsonl`和`.ass`两种。给的是`.ass`时,如果同目录下已经有同名的非空`.jsonl`就直接用,否则按 ASS 里的`PlayResX`/`PlayResY`、字号和`\move`弹道把弹幕还原成 jsonl,并把还原结果写回弹幕文件旁边,下次再合就不用重新解析。`-out`不指定时输出到弹幕文件同目录,重名会自动加`(2)`之类的后缀,不会覆盖已有文件。ffmpeg 的路径除了`-ffmpeg`参数,也会读设置文件里的`ffmpegPath`;填相对路径时按程序所在目录解析,也可以填它所在的文件夹。
|
`-danmuburn`的弹幕文件支持`.jsonl`和`.ass`两种。给的是`.ass`时,如果同目录下已经有同名的非空`.jsonl`就直接用,否则按 ASS 里的`PlayResX`/`PlayResY`、字号和`\move`弹道把弹幕还原成 jsonl,并把还原结果写回弹幕文件旁边,下次再合就不用重新解析。`-out`不指定时输出到弹幕文件同目录,重名会自动加`(2)`之类的后缀,不会覆盖已有文件。ffmpeg 的路径除了`-ffmpeg`参数,也会读设置文件里的`ffmpegPath`;填相对路径时按程序所在目录解析,也可以填它所在的文件夹。
|
||||||
|
|
||||||
头像会按 uid 缓存到录制文件夹下的`avatar_cache`里,圆形裁切后的直径与弹幕文字的墨迹高度一致。头像图片支持 jpg、png、gif 和静态 webp:gif(包括多帧动图)取第一帧来裁切,动画 webp 解不开、遇到就只烧文字。上游原版的`.ass`里没有头像链接(`nick.ass`只有昵称和 uid),遇到这种情况会按 uid 向 AcFun 的`userInfo`接口反查头像,查不到的只烧文字。反查走网络且有超时,所以离线环境下合并会退化成纯文字弹幕并给出警告。
|
头像会按 uid 缓存到**程序所在目录**下的`avatar_cache`里(不放在录播目录,所以换保存目录、甚至换盘都不用重新下载);程序启动合并时会顺手把旧位置(以前存在录播目录下的`avatar_cache`)里已有的头像搬过来。圆形裁切后的直径与弹幕文字的墨迹高度一致。头像图片支持 jpg、png、gif 和静态 webp:gif(包括多帧动图)取第一帧来裁切,动画 webp 解不开、遇到就只烧文字。上游原版的`.ass`里没有头像链接(`nick.ass`只有昵称和 uid),遇到这种情况会按 uid 向 AcFun 的`userInfo`接口反查头像,查不到的只烧文字。反查走网络且有超时,所以离线环境下合并会退化成纯文字弹幕并给出警告。
|
||||||
|
|
||||||
有 Intel 核显时优先用 QSV 硬件编码(`h264_qsv`),不可用或运行失败时自动回退到`libx264`。
|
有 Intel 核显时优先用 QSV 硬件编码(`h264_qsv`),不可用或运行失败时自动回退到`libx264`。
|
||||||
|
|
||||||
@@ -208,7 +236,9 @@ acfunlive -danmuburn xxx.jsonl -video xxx.flv -ffmpeg D:\tools\ffmpeg.exe
|
|||||||
|
|
||||||
### 自动合并
|
### 自动合并
|
||||||
|
|
||||||
开启`autoMerge`后,主播下播且弹幕和录播都自然结束时,会自动把该场录播和弹幕合成为`xxx_弹幕合并.mp4`,之后按设置删除源视频和弹幕文件,并把成片移动到`mergeOutputDir`。`mergeBitrate`大于 0 时成片使用指定目标码率,可以明显减小成片体积。
|
开启`autoMerge`后,主播下播且弹幕和录播都自然结束时,会自动把该场录播和弹幕合成为`xxx_弹幕合并.mp4`,之后按设置删除源视频和弹幕文件,并把成片移动到`mergeOutputDir`。
|
||||||
|
|
||||||
|
成片码率默认(`mergeBitrate`填 0)按**源片码率 + 500 Kbps**算 —— 烧进去的文字和头像都是高频细节,和源片同码率会糊,所以多给一点余量;源片码率探不到时退回质量模式(QSV 用 ICQ 22、软编用 CRF 18)。想固定体积就自己填一个值。编码器优先用硬件 **HEVC(hevc_qsv)**,同画质比 H.264 省三成左右体积、耗时几乎一样,成片在 mp4 里打`hvc1`标记以便 Apple 系播放;显卡不支持时依次回退到`h264_qsv`、`libx264`。注意 Windows 自带播放器要装 HEVC 扩展才能放,PotPlayer、VLC、mpv 不用。
|
||||||
|
|
||||||
直播流中途中断时录制会自动重连并产生新的视频分段,合并会把**所有分段拼成一个完整视频**,弹幕时间轴也按各段时长重新对齐,断流期间的弹幕挪到下一段开头,所以后半场不会丢弹幕。分段时长探测不到时会退化为只合并第一段并给出提示。
|
直播流中途中断时录制会自动重连并产生新的视频分段,合并会把**所有分段拼成一个完整视频**,弹幕时间轴也按各段时长重新对齐,断流期间的弹幕挪到下一段开头,所以后半场不会丢弹幕。分段时长探测不到时会退化为只合并第一段并给出提示。
|
||||||
|
|
||||||
@@ -216,21 +246,55 @@ acfunlive -danmuburn xxx.jsonl -video xxx.flv -ffmpeg D:\tools\ffmpeg.exe
|
|||||||
|
|
||||||
弹幕字号默认沿用弹幕文件里记的字号(1080p 下通常是 48~60),嫌大可以调`弹幕字号`,头像会跟着等比缩小。合并前会按最终字号把弹幕重新排一遍弹道:从上游原版的`.ass`还原出来的弹幕,原本是按「只有内容」的宽度占位的,而渲染用的是「昵称(uid):内容 + 头像」的宽度,占位更久,照搬原弹道会追尾叠字——重排按完整宽度重新分配,同一条弹道上不会再有两条弹幕叠在一起。
|
弹幕字号默认沿用弹幕文件里记的字号(1080p 下通常是 48~60),嫌大可以调`弹幕字号`,头像会跟着等比缩小。合并前会按最终字号把弹幕重新排一遍弹道:从上游原版的`.ass`还原出来的弹幕,原本是按「只有内容」的宽度占位的,而渲染用的是「昵称(uid):内容 + 头像」的宽度,占位更久,照搬原弹道会追尾叠字——重排按完整宽度重新分配,同一条弹道上不会再有两条弹幕叠在一起。
|
||||||
|
|
||||||
|
弹幕文件里的坐标系(`PlayResX`/`PlayResY`)记的也是录制当时的画面尺寸,老文件常和现在的视频对不上(例如 960x540 的弹幕文件配 720x1280 的竖屏视频)。这种情况下字幕渲染器会按「视频高 ÷ 弹幕高」缩放文字和坐标,而头像叠加用的是视频像素坐标,两边不在一个坐标系里:头像显得小,而且越往下的弹幕错得越多,看起来像和文字分开了。合并时会先探测视频分辨率,不一致就把坐标系统一到视频上(字号按高度比换算,跟画面的相对大小不变;`弹幕字号`填了值就按成片像素直接用),头像和文字因此始终对得上。
|
||||||
|
|
||||||
停止录播时会向 ffmpeg 发送正常退出的指令,让它把文件正常收尾(直接杀进程会在文件尾部留下损坏的数据包,mp4 甚至会因为缺少 moov atom 而整个读不出来)。
|
停止录播时会向 ffmpeg 发送正常退出的指令,让它把文件正常收尾(直接杀进程会在文件尾部留下损坏的数据包,mp4 甚至会因为缺少 moov atom 而整个读不出来)。
|
||||||
|
|
||||||
`autoMerge`、`mergeDelVideo`、`mergeDelDanmu`三项互不影响,可以同时开启:第一项决定是否自动合并,后两项是合并成功后的清理动作。
|
`autoMerge`、`mergeDelVideo`、`mergeDelDanmu`三项互不影响,可以同时开启:第一项决定是否自动合并,后两项是合并成功后的清理动作。
|
||||||
|
|
||||||
### 手动合并
|
### 手动合并
|
||||||
|
|
||||||
web UI 的`合并`分区里的`手动合并`用来自己挑视频和弹幕出片,适合手动停止录制过的一场、或者手里只有零散文件的情况。
|
web UI 的`合并`分区里的`手动合并`用来自己挑视频和弹幕出片,适合手动停止录制过的一场、或者手里只有零散文件的情况。**可以一次排多个任务,每个任务一套自己的文件和参数。**
|
||||||
|
|
||||||
- **视频**:既可以在文本框里一行一个地填,也可以从录播目录的下拉列表里多选(选完自动填进文本框)。填的名字不带路径就按录播目录找,带路径的相对和绝对都能用,所以录播目录以外的文件也合得来。粘贴路径时带上的引号会被自动去掉。
|
选好视频后会按文件名自动归组:文件名是`2026-09-25 01-00-00 主播名 标题.mp4`这种格式,去掉开头的时间和扩展名后剩下的(主播名 + 标题)相同、而且相邻两段录制时间差在 2 小时以内的算同一场——同一场断流分成的几段归到一个任务里、先拼起来再合,不同场次各排一个任务、依次合出多个成片。归错了(比如同一天连着两场、标题又一样但间隔在 2 小时内)可以点任务行上的`拆成单独任务`把它们拆开。
|
||||||
- **弹幕**:支持`.jsonl`和`.ass`。填`.ass`时会先还原成 jsonl 再合并(规则和`-danmuburn`一样,优先用同名的 jsonl),还原结果写回录播目录,页面的下拉列表里也会出现。
|
|
||||||
|
每个任务行里能单独改:
|
||||||
|
|
||||||
|
- **视频**:行里列出这一组的文件,可以整组移除或拆成一人一个任务。既可以从录播目录的下拉列表里多选,也可以点`浏览`从别的文件夹挑。填的名字不带路径就按录播目录找,带路径的相对和绝对都能用。
|
||||||
|
- **弹幕**:支持`.jsonl`和`.ass`。填`.ass`时会先还原成 jsonl 再合并(规则和`-danmuburn`一样,优先用同名的 jsonl)。**选好视频会自动找同名弹幕文件**(同目录下依次找`.jsonl`、`.ass`、`.nick.ass`,同目录没有再去录播目录找一次),找到就自动填上;找不到就保持空着,用行里的`…`按钮手选。
|
||||||
- **时间偏移**:留空时按文件名里的录制时间自动推算(视频时间 = 弹幕时间 + 偏移),也可以自己填,比如录播比弹幕晚开始 3 秒就填`3`。
|
- **时间偏移**:留空时按文件名里的录制时间自动推算(视频时间 = 弹幕时间 + 偏移),也可以自己填,比如录播比弹幕晚开始 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 / 去掉音轨 |
|
||||||
|
|
||||||
|
每行下面实时显示**预估产物大小**和相对源片的百分比(体积是服务端按「码率 × 时长」算的,目标码率模式是准数,质量模式只给粗估并标注依据),列表底部还有一整批的合计。调好第一个之后点`把第一个的参数套到全部`可以一次性铺给其它行。
|
||||||
|
|
||||||
|
转码和合并共用同一张进度卡片、同一个任务位,所以一次只跑一个文件,排着的按列表顺序来,也能在`监控`页暂停和停止;停止只丢当前这个,已经在输出目录里落地的成品不受影响。
|
||||||
|
|
||||||
### 通知推送
|
### 通知推送
|
||||||
|
|
||||||
|
|||||||
+13
-14
@@ -139,12 +139,14 @@ func speakerText(name string, uid int64, content string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 从 ASS 文件还原弹幕记录,支持原格式(\move)和昵称头像版(\an7\move)
|
// 从 ASS 文件还原弹幕记录,支持原格式(\move)和昵称头像版(\an7\move)
|
||||||
func parseAssFile(path string) (danmuMeta, []danmuRecord, error) {
|
// 第二个返回值是没能解析出来的行数,日志交给调用方打:页面的「检查」和真正的合并
|
||||||
|
// 会各解析一遍同一个 .ass,日志只该出现一次
|
||||||
|
func parseAssFile(path string) (danmuMeta, []danmuRecord, int, error) {
|
||||||
var meta danmuMeta
|
var meta danmuMeta
|
||||||
|
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return meta, nil, err
|
return meta, nil, 0, err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
@@ -157,6 +159,9 @@ func parseAssFile(path string) (danmuMeta, []danmuRecord, error) {
|
|||||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := strings.TrimRight(scanner.Text(), "\r")
|
line := strings.TrimRight(scanner.Text(), "\r")
|
||||||
|
// 有些编辑器/工具保存的 ASS 头上有 UTF-8 BOM,第一个 "[" 会被它挡住:
|
||||||
|
// 整份文件进不了任何一节,最后只会报一句「可能不是弹幕文件」,跟真实原因完全对不上
|
||||||
|
line = strings.TrimPrefix(line, "\ufeff")
|
||||||
t := strings.TrimSpace(line)
|
t := strings.TrimSpace(line)
|
||||||
if t == "" || strings.HasPrefix(t, ";") {
|
if t == "" || strings.HasPrefix(t, ";") {
|
||||||
continue
|
continue
|
||||||
@@ -272,33 +277,27 @@ func parseAssFile(path string) (danmuMeta, []danmuRecord, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := scanner.Err(); err != nil {
|
if err := scanner.Err(); err != nil {
|
||||||
return meta, nil, err
|
return meta, nil, bad, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if meta.PlayResX == 0 || meta.PlayResY == 0 {
|
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 {
|
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 len(records) == 0 {
|
||||||
if bad > 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 })
|
sort.SliceStable(records, func(i, j int) bool { return records[i].Time < records[j].Time })
|
||||||
// 文件名前缀就是录制开始的时刻,用它算偏移最稳
|
// 文件名前缀就是录制开始的时刻,用它算偏移最稳
|
||||||
meta.StartTime = nameStartNs(path)
|
meta.StartTime = nameStartNs(path)
|
||||||
if bad > 0 {
|
return meta, records, bad, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按本程序的 jsonl 格式写文件(首行是元数据)
|
// 按本程序的 jsonl 格式写文件(首行是元数据)
|
||||||
|
|||||||
+123
-30
@@ -5,6 +5,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -28,10 +29,15 @@ func finishPart(liveID string, danmu bool) {
|
|||||||
info.isRecording = false
|
info.isRecording = false
|
||||||
info.recordDone = true
|
info.recordDone = true
|
||||||
}
|
}
|
||||||
shouldMerge := config.AutoMerge && mainCtx.Err() == nil &&
|
// 弹幕文件写坏过就别合并了:成片会缺一大截弹幕,界面上看不出是缺还是本来就没弹幕
|
||||||
|
broke := info.danmuWriteFailed
|
||||||
|
shouldMerge := config.AutoMerge && mainCtx.Err() == nil && !broke &&
|
||||||
!info.danmuStopped && !info.recordStopped &&
|
!info.danmuStopped && !info.recordStopped &&
|
||||||
info.danmuDone && info.recordDone && !info.mergeStarted &&
|
info.danmuDone && info.recordDone && !info.mergeStarted &&
|
||||||
info.assFile != "" && info.firstRecordFile != ""
|
info.assFile != "" && info.firstRecordFile != ""
|
||||||
|
if broke && config.AutoMerge && info.danmuDone && info.recordDone && !info.mergeStarted {
|
||||||
|
lPrintWarn("本场弹幕文件在录制时写盘出错,跳过自动合并(录播和弹幕文件都留着)")
|
||||||
|
}
|
||||||
var segs []recordSegment
|
var segs []recordSegment
|
||||||
if shouldMerge {
|
if shouldMerge {
|
||||||
info.mergeStarted = true
|
info.mergeStarted = true
|
||||||
@@ -79,11 +85,25 @@ func findMovedFile(path string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 收拢到实际位置:找不着时退回原路径,让后面的报错还能指出「本来想找哪儿」
|
||||||
|
func resolveAfterMove(path string) string {
|
||||||
|
if p := findMovedFile(path); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
// 录播因为断流可能分成多段:单段直接使用,多段先拼成完整视频并把弹幕时间映射到拼接后的时间轴。
|
// 录播因为断流可能分成多段:单段直接使用,多段先拼成完整视频并把弹幕时间映射到拼接后的时间轴。
|
||||||
// 返回合并要用的视频、字幕文件和弹幕相对视频起点的时间偏移,以及清理临时文件的函数。
|
// 返回合并要用的视频、字幕文件和弹幕相对视频起点的时间偏移、**实际参与合并的源文件**,
|
||||||
func prepareForMerge(ffmpegFile string, segs []recordSegment, jsonlFile string, danmuStartNs int64) (string, string, float64, func(), error) {
|
// 以及清理临时文件的函数。used 是给「合并完删源视频」用的:中途回退成「只合并第一个分段」时,
|
||||||
|
// 后面的分段并没有进成片,按原分段列表全删就等于把后半场永久丢了。
|
||||||
|
func prepareForMerge(ffmpegFile string, segs []recordSegment, jsonlFile string, danmuStartNs int64) (string, string, float64, []string, func(), error) {
|
||||||
noop := func() {}
|
noop := func() {}
|
||||||
|
|
||||||
|
// 弹幕文件同样会被移动过去。以前只有视频分段走了查找,弹幕还拿着录制时的路径,
|
||||||
|
// 结果就是视频找得到、弹幕找不到,合并以「jsonl 文件不存在」告终
|
||||||
|
jsonlFile = resolveAfterMove(jsonlFile)
|
||||||
|
|
||||||
// 录制结束时文件可能已经被移动到保存目录,逐个找实际位置
|
// 录制结束时文件可能已经被移动到保存目录,逐个找实际位置
|
||||||
files := make([]string, 0, len(segs))
|
files := make([]string, 0, len(segs))
|
||||||
starts := make([]int64, 0, len(segs))
|
starts := make([]int64, 0, len(segs))
|
||||||
@@ -94,11 +114,11 @@ func prepareForMerge(ffmpegFile string, segs []recordSegment, jsonlFile string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(files) == 0 {
|
if len(files) == 0 {
|
||||||
return "", "", 0, noop, errors.New("找不到录播视频文件")
|
return "", "", 0, nil, noop, errors.New("找不到录播视频文件")
|
||||||
}
|
}
|
||||||
if len(files) == 1 {
|
if len(files) == 1 {
|
||||||
offset := float64(danmuStartNs-starts[0]) / 1e9
|
offset := float64(danmuStartNs-starts[0]) / 1e9
|
||||||
return files[0], jsonlFile, offset, noop, nil
|
return files[0], jsonlFile, offset, files, noop, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
lPrintf("本场录播中途中断过,共 %d 个分段,先拼接成完整视频再合并", len(files))
|
lPrintf("本场录播中途中断过,共 %d 个分段,先拼接成完整视频再合并", len(files))
|
||||||
@@ -110,17 +130,33 @@ func prepareForMerge(ffmpegFile string, segs []recordSegment, jsonlFile string,
|
|||||||
if durs[i] <= 0 {
|
if durs[i] <= 0 {
|
||||||
lPrintWarnf("无法获取分段 %s 的时长,本场只合并第一个分段(后半场没有弹幕)", filepath.Base(f))
|
lPrintWarnf("无法获取分段 %s 的时长,本场只合并第一个分段(后半场没有弹幕)", filepath.Base(f))
|
||||||
offset := float64(danmuStartNs-starts[0]) / 1e9
|
offset := float64(danmuStartNs-starts[0]) / 1e9
|
||||||
return files[0], jsonlFile, offset, noop, nil
|
return files[0], jsonlFile, offset, files[:1], noop, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dir := filepath.Dir(jsonlFile)
|
dir := filepath.Dir(jsonlFile)
|
||||||
// 拼接出来的中间文件沿用分段的封装格式,concat 用 -c copy 时容器一致最稳
|
// 拼接出来的中间文件沿用分段的封装格式,concat 用 -c copy 时容器一致最稳
|
||||||
joined := filepath.Join(dir, chunkTempPrefix+"joined"+filepath.Ext(files[0]))
|
joined := filepath.Join(dir, chunkTempPrefix+"joined"+filepath.Ext(files[0]))
|
||||||
if err := concatChunks(ffmpegFile, files, joined, dir); err != nil {
|
jobSetPhase(jobPhaseJoin)
|
||||||
|
joinedSec := 0.0
|
||||||
|
for _, d := range durs {
|
||||||
|
joinedSec += d
|
||||||
|
}
|
||||||
|
jobSetTotal(joinedSec)
|
||||||
|
if err := concatChunks(ffmpegFile, files, joined, dir, func(outSec, speed, fps float64, size int64) {
|
||||||
|
if joinedSec > 0 {
|
||||||
|
jobSetPhasePercent(outSec / joinedSec)
|
||||||
|
}
|
||||||
|
}); err != nil {
|
||||||
|
// 任务被手动停止时不要再退化成「只合并第一个分段」,
|
||||||
|
// 也别把拼到一半的中间文件留下
|
||||||
|
_ = os.Remove(joined)
|
||||||
|
if errors.Is(err, errJobStopped) {
|
||||||
|
return "", "", 0, nil, noop, errJobStopped
|
||||||
|
}
|
||||||
lPrintWarnf("拼接录播分段失败,本场只合并第一个分段(后半场没有弹幕):%v", err)
|
lPrintWarnf("拼接录播分段失败,本场只合并第一个分段(后半场没有弹幕):%v", err)
|
||||||
offset := float64(danmuStartNs-starts[0]) / 1e9
|
offset := float64(danmuStartNs-starts[0]) / 1e9
|
||||||
return files[0], jsonlFile, offset, noop, nil
|
return files[0], jsonlFile, offset, files[:1], noop, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
newJsonl := filepath.Join(dir, chunkTempPrefix+"remapped.jsonl")
|
newJsonl := filepath.Join(dir, chunkTempPrefix+"remapped.jsonl")
|
||||||
@@ -128,14 +164,14 @@ func prepareForMerge(ffmpegFile string, segs []recordSegment, jsonlFile string,
|
|||||||
_ = os.Remove(joined)
|
_ = os.Remove(joined)
|
||||||
lPrintWarnf("重映射弹幕时间失败,本场只合并第一个分段(后半场没有弹幕):%v", err)
|
lPrintWarnf("重映射弹幕时间失败,本场只合并第一个分段(后半场没有弹幕):%v", err)
|
||||||
offset := float64(danmuStartNs-starts[0]) / 1e9
|
offset := float64(danmuStartNs-starts[0]) / 1e9
|
||||||
return files[0], jsonlFile, offset, noop, nil
|
return files[0], jsonlFile, offset, files[:1], noop, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup := func() {
|
cleanup := func() {
|
||||||
_ = os.Remove(joined)
|
_ = os.Remove(joined)
|
||||||
_ = os.Remove(newJsonl)
|
_ = os.Remove(newJsonl)
|
||||||
}
|
}
|
||||||
return joined, newJsonl, 0, cleanup, nil
|
return joined, newJsonl, 0, files, cleanup, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 把弹幕时间从「真实时间轴」映射到「分段拼接后的时间轴」
|
// 把弹幕时间从「真实时间轴」映射到「分段拼接后的时间轴」
|
||||||
@@ -225,31 +261,63 @@ func remapJsonlTimes(srcFile, dstFile string, starts []int64, durs []float64, da
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行自动合并及后续的删除/移动
|
// 自动合并入口:排队跑完一场,把结果写进任务进度;日志措辞仍由内部负责
|
||||||
func autoMerge(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int64) {
|
func autoMerge(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int64) {
|
||||||
|
// 多个主播可能同时下播,而全局只有一份任务状态:先排队,跑到了再登记进度
|
||||||
|
if jobActive() {
|
||||||
|
lPrintf("已有合并任务在跑(%s),本场先排队等待", jobBusyName())
|
||||||
|
}
|
||||||
|
err := runMergeExclusive(func() error {
|
||||||
|
return autoMergeJob(uid, segs, jsonlFile, danmuStartNs)
|
||||||
|
})
|
||||||
|
if err == errJobStopped {
|
||||||
|
// 停止不是故障,内部不打印,这里补一句
|
||||||
|
lPrintWarn("自动合并已被手动停止" + longID(uid))
|
||||||
|
}
|
||||||
|
jobFinish(jobErrText(err))
|
||||||
|
// 失败要推一条,否则只有盯着控制台才知道这场没合成出来
|
||||||
|
notifyMergeFailure(uid, autoMergeOutFile(jsonlFile), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把错误翻成给界面看的原因:正常结束和「没有弹幕」都不算失败
|
||||||
|
func jobErrText(err error) string {
|
||||||
|
switch err {
|
||||||
|
case nil, errNoDanmu:
|
||||||
|
return ""
|
||||||
|
case errJobStopped:
|
||||||
|
return errJobStopped.Error()
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动合并成片的最终路径:成片目录 + 与弹幕文件同名的「_弹幕合并.mp4」
|
||||||
|
func autoMergeOutFile(jsonlFile string) string {
|
||||||
|
name := filepath.Base(strings.TrimSuffix(jsonlFile, ".jsonl") + "_弹幕合并.mp4")
|
||||||
|
return filepath.Join(mergeSaveDir(), name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行自动合并及后续的删除/移动。日志都在内部按原措辞打印,返回值只给上层写进度用
|
||||||
|
func autoMergeJob(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int64) error {
|
||||||
lPrintln("开始自动合并" + longID(uid) + "的录播和弹幕层")
|
lPrintln("开始自动合并" + longID(uid) + "的录播和弹幕层")
|
||||||
|
|
||||||
|
// 录制结束时弹幕文件可能已被移动到保存目录,先把路径收拢到实际位置:
|
||||||
|
// 中间成片写到哪、事后删哪几个弹幕文件都按这一条路径来,用旧路径会指向错误的地方
|
||||||
|
jsonlFile = resolveAfterMove(jsonlFile)
|
||||||
|
|
||||||
// 视频和弹幕是一次录制产生的,文件名去掉扩展名后应该一致(扩展名由 recordFormat 决定)
|
// 视频和弹幕是一次录制产生的,文件名去掉扩展名后应该一致(扩展名由 recordFormat 决定)
|
||||||
if len(segs) > 0 {
|
if len(segs) > 0 {
|
||||||
videoBase := filepath.Base(strings.TrimSuffix(segs[0].file, filepath.Ext(segs[0].file)))
|
videoBase := filepath.Base(strings.TrimSuffix(segs[0].file, filepath.Ext(segs[0].file)))
|
||||||
jsonlBase := filepath.Base(strings.TrimSuffix(jsonlFile, ".jsonl"))
|
jsonlBase := filepath.Base(strings.TrimSuffix(jsonlFile, ".jsonl"))
|
||||||
if videoBase != jsonlBase {
|
if videoBase != jsonlBase {
|
||||||
lPrintWarnf("视频文件(%s)和弹幕文件(%s)不是同一场录制,取消自动合并", segs[0].file, jsonlFile)
|
lPrintWarnf("视频文件(%s)和弹幕文件(%s)不是同一场录制,取消自动合并", segs[0].file, jsonlFile)
|
||||||
return
|
return errors.New("视频文件和弹幕文件不是同一场录制")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ffmpegFile := getFFmpeg()
|
ffmpegFile := getFFmpeg()
|
||||||
if ffmpegFile == "" {
|
if ffmpegFile == "" {
|
||||||
lPrintErr("自动合并失败:找不到 ffmpeg,请安装并加入 PATH,或在 web UI 设置里填写 ffmpeg 路径")
|
lPrintErr("自动合并失败:找不到 ffmpeg,请安装并加入 PATH,或在 web UI 设置里填写 ffmpeg 路径")
|
||||||
return
|
return errors.New("找不到 ffmpeg,请安装并加入 PATH,或在 web UI 设置里填写 ffmpeg 路径")
|
||||||
}
|
|
||||||
|
|
||||||
video, jsonl, offset, cleanup, err := prepareForMerge(ffmpegFile, segs, jsonlFile, danmuStartNs)
|
|
||||||
defer cleanup()
|
|
||||||
if err != nil {
|
|
||||||
lPrintErrf("自动合并失败:%v", err)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 成片先在本地合成,再落到成片文件夹:成片目录可能是网络盘或外接盘,
|
// 成片先在本地合成,再落到成片文件夹:成片目录可能是网络盘或外接盘,
|
||||||
@@ -257,28 +325,52 @@ func autoMerge(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int
|
|||||||
mergeDir := mergeSaveDir()
|
mergeDir := mergeSaveDir()
|
||||||
if err := os.MkdirAll(mergeDir, 0755); err != nil {
|
if err := os.MkdirAll(mergeDir, 0755); err != nil {
|
||||||
lPrintWarnf("创建成片文件夹 %s 失败:%v", mergeDir, err)
|
lPrintWarnf("创建成片文件夹 %s 失败:%v", mergeDir, err)
|
||||||
return
|
return fmt.Errorf("创建成片文件夹 %s 失败:%v", mergeDir, err)
|
||||||
}
|
}
|
||||||
outFile := filepath.Join(mergeDir, filepath.Base(strings.TrimSuffix(jsonlFile, ".jsonl")+"_弹幕合并.mp4"))
|
outFile := autoMergeOutFile(jsonlFile)
|
||||||
tmpOut := tempMergeOutput(jsonlFile, filepath.Base(outFile))
|
tmpOut := tempMergeOutput(jsonlFile, filepath.Base(outFile))
|
||||||
|
|
||||||
|
// 输入大小按各分段实际位置合计(录制结束时文件可能已被移动到保存目录)
|
||||||
|
inputBytes := int64(0)
|
||||||
|
for _, s := range segs {
|
||||||
|
if p := findMovedFile(s.file); p != "" {
|
||||||
|
inputBytes += fileSizeOrZero(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !jobStart(jobKindAutoMerge, filepath.Base(outFile), inputBytes, 0) {
|
||||||
|
// 走到这里说明没先拿 mergeSlot,与其把别人的进度冲掉不如直接放弃这一场
|
||||||
|
return errors.New("开始任务失败:还有别的合并任务没结束,本场合并已跳过")
|
||||||
|
}
|
||||||
|
|
||||||
|
video, jsonl, offset, used, cleanup, err := prepareForMerge(ffmpegFile, segs, jsonlFile, danmuStartNs)
|
||||||
|
defer cleanup()
|
||||||
|
if err != nil {
|
||||||
|
lPrintErrf("自动合并失败:%v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// 标记成正在写入,避免在 web UI 的文件管理里把还没写完的成片删掉
|
// 标记成正在写入,避免在 web UI 的文件管理里把还没写完的成片删掉
|
||||||
markFileActive(tmpOut, true)
|
markFileActive(tmpOut, true)
|
||||||
burnErr := burnDanmu(jsonl, video, tmpOut, offset)
|
// 自动合并没有单独的参数,全部沿用全局设置
|
||||||
|
burnErr := burnDanmu(jsonl, video, tmpOut, offset, mergeOptions{})
|
||||||
markFileActive(tmpOut, false)
|
markFileActive(tmpOut, false)
|
||||||
if burnErr != nil {
|
if burnErr != nil {
|
||||||
_ = os.Remove(tmpOut)
|
_ = os.Remove(tmpOut)
|
||||||
if burnErr == errNoDanmu {
|
switch burnErr {
|
||||||
|
case errJobStopped:
|
||||||
|
return errJobStopped
|
||||||
|
case errNoDanmu:
|
||||||
lPrintWarn("弹幕文件里没有弹幕,跳过自动合并")
|
lPrintWarn("弹幕文件里没有弹幕,跳过自动合并")
|
||||||
return
|
return errNoDanmu
|
||||||
}
|
}
|
||||||
lPrintErrf("自动合并失败:%v", burnErr)
|
lPrintErrf("自动合并失败:%v", burnErr)
|
||||||
return
|
return burnErr
|
||||||
}
|
}
|
||||||
|
jobSetPhase(jobPhasePlace)
|
||||||
if err := placeOutput(tmpOut, outFile); err != nil {
|
if err := placeOutput(tmpOut, outFile); err != nil {
|
||||||
_ = os.Remove(tmpOut)
|
_ = os.Remove(tmpOut)
|
||||||
lPrintErrf("自动合并失败:%v", err)
|
lPrintErrf("自动合并失败:%v", err)
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
lPrintln("自动合并完成:" + outFile)
|
lPrintln("自动合并完成:" + outFile)
|
||||||
@@ -286,9 +378,9 @@ func autoMerge(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int
|
|||||||
longID(uid)+" 的录播和弹幕合并完成\n"+outFile)
|
longID(uid)+" 的录播和弹幕合并完成\n"+outFile)
|
||||||
|
|
||||||
if config.MergeDelVideo {
|
if config.MergeDelVideo {
|
||||||
// 源视频可能有多个分段,全部删掉
|
// 只删真正进了成片的分段:拼接中途回退成「只合并第一段」时,后面的分段没被合并,
|
||||||
for _, s := range segs {
|
// 按原分段列表全删就等于把后半场永久丢掉
|
||||||
p := findMovedFile(s.file)
|
for _, p := range used {
|
||||||
if p == "" {
|
if p == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -307,4 +399,5 @@ func autoMerge(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 换掉真的反查动作,返回调用次数
|
||||||
|
func stubLookupAvatarURL(t *testing.T, f func(uid int64) string) *int64 {
|
||||||
|
t.Helper()
|
||||||
|
var calls int64
|
||||||
|
old := lookupAvatarURL
|
||||||
|
lookupAvatarURL = func(uid int64) string {
|
||||||
|
atomic.AddInt64(&calls, 1)
|
||||||
|
return f(uid)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { lookupAvatarURL = old })
|
||||||
|
return &calls
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空进程内的链接记忆,模拟「重启进程」,用完恢复
|
||||||
|
func resetAvatarURLMemo(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
avatarURLMemo.Lock()
|
||||||
|
prevM, prevAt, prevDir, prevLoaded := avatarURLMemo.m, avatarURLMemo.at, avatarURLMemo.dir, avatarURLMemo.loaded
|
||||||
|
avatarURLMemo.m, avatarURLMemo.at, avatarURLMemo.dir, avatarURLMemo.loaded = nil, nil, "", false
|
||||||
|
avatarURLMemo.Unlock()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
avatarURLMemo.Lock()
|
||||||
|
avatarURLMemo.m, avatarURLMemo.at, avatarURLMemo.dir, avatarURLMemo.loaded = prevM, prevAt, prevDir, prevLoaded
|
||||||
|
avatarURLMemo.Unlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接写落盘文件(绕过 append,方便造历史记录)
|
||||||
|
func writeAvatarURLCacheFile(t *testing.T, dir string, lines []string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(dir, avatarURLCacheFile)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body := strings.Join(lines, "\n")
|
||||||
|
if body != "" {
|
||||||
|
body += "\n"
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查到的链接要落盘,重启进程后不再重复反查
|
||||||
|
func TestAvatarURLCacheSurvivesRestart(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
resetAvatarURLMemo(t)
|
||||||
|
calls := stubLookupAvatarURL(t, func(uid int64) string {
|
||||||
|
return fmt.Sprintf("https://example.invalid/%d.png", uid)
|
||||||
|
})
|
||||||
|
|
||||||
|
url1, fetched1 := avatarURLByUID(42, dir)
|
||||||
|
if !fetched1 {
|
||||||
|
t.Fatal("第一次应当真的去查")
|
||||||
|
}
|
||||||
|
if url1 != "https://example.invalid/42.png" {
|
||||||
|
t.Fatalf("拿到的链接不对:%q", url1)
|
||||||
|
}
|
||||||
|
if atomic.LoadInt64(calls) != 1 {
|
||||||
|
t.Fatalf("第一次应当查 1 次,实际 %d", atomic.LoadInt64(calls))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同进程第二次:内存记忆命中,不再查
|
||||||
|
if _, fetched := avatarURLByUID(42, dir); fetched {
|
||||||
|
t.Fatal("同进程内第二次不该再查")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟重启:清空内存,只留落盘文件
|
||||||
|
resetAvatarURLMemo(t)
|
||||||
|
url2, fetched2 := avatarURLByUID(42, dir)
|
||||||
|
if fetched2 {
|
||||||
|
t.Fatal("重启后应当直接用落盘记录,不再向 AcFun 查")
|
||||||
|
}
|
||||||
|
if url2 != url1 {
|
||||||
|
t.Fatalf("落盘记录里的链接应当和当初查到的一样,实际 %q", url2)
|
||||||
|
}
|
||||||
|
if n := atomic.LoadInt64(calls); n != 1 {
|
||||||
|
t.Fatalf("整个过程只该查 1 次,实际 %d 次", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过期的记录要重新查(给主播换头像留出路),没到期的直接用
|
||||||
|
func TestAvatarURLCacheTTL(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
now := time.Now()
|
||||||
|
writeAvatarURLCacheFile(t, dir, []string{
|
||||||
|
fmt.Sprintf(`{"uid":1,"url":"https://old/1.png","at":%d}`, now.Add(-8*24*time.Hour).Unix()),
|
||||||
|
fmt.Sprintf(`{"uid":2,"url":"https://fresh/2.png","at":%d}`, now.Add(-time.Hour).Unix()),
|
||||||
|
})
|
||||||
|
resetAvatarURLMemo(t)
|
||||||
|
stubLookupAvatarURL(t, func(uid int64) string { return "https://rechecked/x.png" })
|
||||||
|
|
||||||
|
if u, fetched := avatarURLByUID(2, dir); fetched || u != "https://fresh/2.png" {
|
||||||
|
t.Fatalf("没到期的记录应当直接用,实际 (%q, fetched=%v)", u, fetched)
|
||||||
|
}
|
||||||
|
if u, fetched := avatarURLByUID(1, dir); !fetched || u != "https://rechecked/x.png" {
|
||||||
|
t.Fatalf("过了有效期的记录应当重新查,实际 (%q, fetched=%v)", u, fetched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件里有重复行、过期行和坏行时,加载后顺手压一遍,别让它一直长
|
||||||
|
func TestAvatarURLCacheCompactsFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
now := time.Now()
|
||||||
|
path := writeAvatarURLCacheFile(t, dir, []string{
|
||||||
|
fmt.Sprintf(`{"uid":7,"url":"https://a/7.png","at":%d}`, now.Add(-3*time.Minute).Unix()),
|
||||||
|
fmt.Sprintf(`{"uid":7,"url":"https://b/7.png","at":%d}`, now.Add(-2*time.Minute).Unix()),
|
||||||
|
fmt.Sprintf(`{"uid":7,"url":"https://c/7.png","at":%d}`, now.Add(-time.Minute).Unix()),
|
||||||
|
fmt.Sprintf(`{"uid":8,"url":"https://old/8.png","at":%d}`, now.Add(-30*24*time.Hour).Unix()),
|
||||||
|
`这不是 json`,
|
||||||
|
})
|
||||||
|
resetAvatarURLMemo(t)
|
||||||
|
stubLookupAvatarURL(t, func(uid int64) string { return "" })
|
||||||
|
|
||||||
|
if u, fetched := avatarURLByUID(7, dir); fetched || u != "https://c/7.png" {
|
||||||
|
t.Fatalf("同一 uid 应当以后写的为准(c),实际 (%q, fetched=%v)", u, fetched)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||||
|
if len(lines) != 1 {
|
||||||
|
t.Fatalf("压缩后应当只剩 1 行有效记录,实际 %d 行:%v", len(lines), lines)
|
||||||
|
}
|
||||||
|
if !strings.Contains(lines[0], "https://c/7.png") {
|
||||||
|
t.Fatalf("压缩后保留的应当是最新的那条:%s", lines[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新查到的链接要追加到落盘文件里
|
||||||
|
func TestAvatarURLCacheAppendsNewEntries(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
resetAvatarURLMemo(t)
|
||||||
|
stubLookupAvatarURL(t, func(uid int64) string { return fmt.Sprintf("https://x/%d.png", uid) })
|
||||||
|
|
||||||
|
if _, fetched := avatarURLByUID(11, dir); !fetched {
|
||||||
|
t.Fatal("第一次应当去查")
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, avatarURLCacheFile))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("落盘文件应当已生成:%v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), `"uid":11`) || !strings.Contains(string(data), "https://x/11.png") {
|
||||||
|
t.Fatalf("落盘内容不对:%s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 头像并发获取:首次全部下载,第二次全部命中缓存,不会重复下载
|
||||||
|
func TestFetchAvatarsReusesCache(t *testing.T) {
|
||||||
|
withJobFlags(t, true, false, false)
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
var hits int64
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
atomic.AddInt64(&hits, 1)
|
||||||
|
_ = png.Encode(w, image.NewRGBA(image.Rect(0, 0, 8, 8)))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
const n = 20
|
||||||
|
uids := make([]int64, 0, n)
|
||||||
|
urlByUID := make(map[int64]string, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
uid := int64(1000 + i)
|
||||||
|
uids = append(uids, uid)
|
||||||
|
urlByUID[uid] = fmt.Sprintf("%s/av%d.png", srv.URL, uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
paths, stat, err := fetchAvatars(uids, urlByUID, dir, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("获取头像不该出错:%v", err)
|
||||||
|
}
|
||||||
|
if len(paths) != n {
|
||||||
|
t.Fatalf("应当拿到 %d 个头像,实际 %d", n, len(paths))
|
||||||
|
}
|
||||||
|
if stat.imgDownloaded != n || stat.imgReused != 0 || stat.failed != 0 {
|
||||||
|
t.Fatalf("首次应当全部下载:下载 %d 复用 %d 失败 %d", stat.imgDownloaded, stat.imgReused, stat.failed)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt64(&hits); got != n {
|
||||||
|
t.Fatalf("首次应当请求 %d 次,实际 %d", n, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二次:全部命中缓存,一次网络都不该发
|
||||||
|
paths2, stat2, err := fetchAvatars(uids, urlByUID, dir, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("第二次获取头像不该出错:%v", err)
|
||||||
|
}
|
||||||
|
if len(paths2) != n {
|
||||||
|
t.Fatalf("第二次也应当拿到 %d 个头像,实际 %d", n, len(paths2))
|
||||||
|
}
|
||||||
|
if stat2.imgReused != n || stat2.imgDownloaded != 0 {
|
||||||
|
t.Fatalf("第二次应当全部复用缓存:复用 %d 下载 %d", stat2.imgReused, stat2.imgDownloaded)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt64(&hits); got != n {
|
||||||
|
t.Fatalf("第二次不该再下载,累计请求数应当还是 %d,实际 %d", n, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计要能区分「链接复用」和「真的去查了」
|
||||||
|
func TestFetchAvatarsCountsLinkLookups(t *testing.T) {
|
||||||
|
withJobFlags(t, true, false, false)
|
||||||
|
dir := t.TempDir()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = png.Encode(w, image.NewRGBA(image.Rect(0, 0, 8, 8)))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resetAvatarURLMemo(t)
|
||||||
|
lookups := stubLookupAvatarURL(t, func(uid int64) string { return srv.URL + "/looked.png" })
|
||||||
|
|
||||||
|
uids := []int64{1, 2, 3}
|
||||||
|
urlByUID := map[int64]string{
|
||||||
|
1: srv.URL + "/own.png", // 自带的链接
|
||||||
|
2: "",
|
||||||
|
3: "",
|
||||||
|
}
|
||||||
|
paths, stat, err := fetchAvatars(uids, urlByUID, dir, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("不该出错:%v", err)
|
||||||
|
}
|
||||||
|
if len(paths) != 3 {
|
||||||
|
t.Fatalf("三个用户都该拿到头像,实际 %d", len(paths))
|
||||||
|
}
|
||||||
|
if stat.fetched != 2 {
|
||||||
|
t.Fatalf("应当有 2 个走了反查,实际 %d", stat.fetched)
|
||||||
|
}
|
||||||
|
if stat.reusedLink != 0 {
|
||||||
|
t.Fatalf("第一次没有可复用的链接,实际 %d", stat.reusedLink)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt64(lookups); got != 2 {
|
||||||
|
t.Fatalf("反查动作应当只发生 2 次,实际 %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再跑一次:这两个 uid 的链接已经记下了,不该再查
|
||||||
|
_, stat2, err := fetchAvatars(uids, urlByUID, dir, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("第二次不该出错:%v", err)
|
||||||
|
}
|
||||||
|
if stat2.reusedLink != 2 || stat2.fetched != 0 {
|
||||||
|
t.Fatalf("第二次应当复用链接:复用 %d 反查 %d", stat2.reusedLink, stat2.fetched)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt64(lookups); got != 2 {
|
||||||
|
t.Fatalf("第二次不该再查链接,累计应当还是 2 次,实际 %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 任务被停止时,获取头像要立刻收工并返回 errJobStopped
|
||||||
|
func TestFetchAvatarsStopsWhenAsked(t *testing.T) {
|
||||||
|
withJobFlags(t, true, false, true)
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, _, err := fetchAvatars([]int64{1}, map[int64]string{1: "http://127.0.0.1:1/x.png"}, dir, "")
|
||||||
|
if !errors.Is(err, errJobStopped) {
|
||||||
|
t.Fatalf("应当返回 errJobStopped,实际 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计里的「新下载」要能识别出「这个文件本来就在」
|
||||||
|
func TestFetchAvatarsCountsReuseAfterRestart(t *testing.T) {
|
||||||
|
withJobFlags(t, true, false, false)
|
||||||
|
dir := t.TempDir()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = png.Encode(w, image.NewRGBA(image.Rect(0, 0, 8, 8)))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
uids := []int64{5001}
|
||||||
|
urlByUID := map[int64]string{5001: srv.URL + "/a.png"}
|
||||||
|
|
||||||
|
// 先下载一次,再把内存里的链接记忆清掉(模拟重启后再合并同一场)
|
||||||
|
if _, stat, err := fetchAvatars(uids, urlByUID, dir, ""); err != nil || stat.imgDownloaded != 1 {
|
||||||
|
t.Fatalf("首次应当下载 1 个:%v %+v", err, stat)
|
||||||
|
}
|
||||||
|
resetAvatarURLMemo(t)
|
||||||
|
if _, stat, err := fetchAvatars(uids, urlByUID, dir, ""); err != nil || stat.imgReused != 1 || stat.imgDownloaded != 0 {
|
||||||
|
t.Fatalf("重启后应当复用缓存:%v %+v", err, stat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 落盘文件写坏/读不出时不能影响合并,只是这次多查几遍
|
||||||
|
func TestAvatarURLCacheBrokenFileIsIgnored(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeAvatarURLCacheFile(t, dir, []string{"{坏的一行", "还是坏的"})
|
||||||
|
resetAvatarURLMemo(t)
|
||||||
|
calls := stubLookupAvatarURL(t, func(uid int64) string { return "https://ok/1.png" })
|
||||||
|
|
||||||
|
u, fetched := avatarURLByUID(1, dir)
|
||||||
|
if !fetched || u != "https://ok/1.png" {
|
||||||
|
t.Fatalf("坏文件应当忽略并照常反查,实际 (%q, fetched=%v)", u, fetched)
|
||||||
|
}
|
||||||
|
if atomic.LoadInt64(calls) != 1 {
|
||||||
|
t.Fatalf("应当查 1 次,实际 %d", atomic.LoadInt64(calls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认落盘用的是头像缓存目录里的固定文件名(别改到别处去)
|
||||||
|
func TestAvatarURLCacheFilePath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
resetAvatarURLMemo(t)
|
||||||
|
stubLookupAvatarURL(t, func(uid int64) string { return "https://x/1.png" })
|
||||||
|
avatarURLByUID(1, dir)
|
||||||
|
|
||||||
|
want := filepath.Join(dir, avatarURLCacheFile)
|
||||||
|
if _, err := os.Stat(want); err != nil {
|
||||||
|
t.Fatalf("落盘文件应当在 %s:%v", want, err)
|
||||||
|
}
|
||||||
|
// 顺带确认它能被当成普通 jsonl 读出来(一行一条)
|
||||||
|
f, err := os.Open(want)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
data, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "https://x/1.png") {
|
||||||
|
t.Fatalf("内容不对:%s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 头像确实是并发获取的:串行做几百个用户要好几分钟,这条锁住「同时有多个请求在跑」
|
||||||
|
func TestFetchAvatarsRunsConcurrently(t *testing.T) {
|
||||||
|
withJobFlags(t, true, false, false)
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
var active, maxActive int64
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cur := atomic.AddInt64(&active, 1)
|
||||||
|
for {
|
||||||
|
old := atomic.LoadInt64(&maxActive)
|
||||||
|
if cur <= old || atomic.CompareAndSwapInt64(&maxActive, old, cur) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(40 * time.Millisecond) // 让并发的重叠看得见
|
||||||
|
atomic.AddInt64(&active, -1)
|
||||||
|
_ = png.Encode(w, image.NewRGBA(image.Rect(0, 0, 8, 8)))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
const n = 12
|
||||||
|
uids := make([]int64, 0, n)
|
||||||
|
urlByUID := make(map[int64]string, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
uid := int64(2000 + i)
|
||||||
|
uids = append(uids, uid)
|
||||||
|
urlByUID[uid] = fmt.Sprintf("%s/c%d.png", srv.URL, uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := fetchAvatars(uids, urlByUID, dir, ""); err != nil {
|
||||||
|
t.Fatalf("不该出错:%v", err)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt64(&maxActive); got < 2 {
|
||||||
|
t.Fatalf("头像应当是并发获取的,同时最多只有 %d 个请求在跑(串行会把几百人拖成几分钟)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+187
@@ -2,13 +2,22 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/sha1"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/gif"
|
"image/gif"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
"image/png"
|
"image/png"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 2x2 的静态 webp(纯红),用来确认 webp 解码器确实注册上了
|
// 2x2 的静态 webp(纯红),用来确认 webp 解码器确实注册上了
|
||||||
@@ -109,3 +118,181 @@ func TestDecodeAvatarGifGoesThroughCircleAvatar(t *testing.T) {
|
|||||||
t.Fatal("编码出来是空的")
|
t.Fatal("编码出来是空的")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 头像缓存在程序所在目录,不再跟着保存目录跑
|
||||||
|
func TestAvatarCachePathUnderExeDir(t *testing.T) {
|
||||||
|
old := exeDir
|
||||||
|
defer func() { exeDir = old }()
|
||||||
|
|
||||||
|
exeDir = filepath.Join("C:", "app", "acfunlive")
|
||||||
|
want := filepath.Join("C:", "app", "acfunlive", avatarCacheDir)
|
||||||
|
if got := avatarCachePath(); got != want {
|
||||||
|
t.Errorf("应缓存到 %q,实际 %q", want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取不到程序目录时退回相对路径,别拼出 /avatar_cache 这种绝对路径
|
||||||
|
exeDir = ""
|
||||||
|
if got := avatarCachePath(); got != avatarCacheDir {
|
||||||
|
t.Errorf("没有程序目录时应退回 %q,实际 %q", avatarCacheDir, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 旧版本把头像存在录播目录里,换保存目录后要能直接搬过来用,不重新下载
|
||||||
|
func TestEnsureAvatarMigratesLegacyCache(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
cacheDir := filepath.Join(root, "program", avatarCacheDir)
|
||||||
|
legacyDir := filepath.Join(root, "record", avatarCacheDir)
|
||||||
|
if err := os.MkdirAll(legacyDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 这个地址连不通:命中旧缓存就不该去下载(下载会重试 3 次,很慢)
|
||||||
|
url := "http://127.0.0.1:9/never.png"
|
||||||
|
sum := sha1.Sum([]byte(url))
|
||||||
|
name := fmt.Sprintf("%d_%x.png", int64(7), sum[:6])
|
||||||
|
old := filepath.Join(legacyDir, name)
|
||||||
|
// 旧位置放的是真 PNG(旧版本自己也是用 png.Encode 写的):
|
||||||
|
// 现在搬过来之前会校验完整性,随便一段字节的话会被当成半截文件丢掉
|
||||||
|
var pngBuf bytes.Buffer
|
||||||
|
if err := png.Encode(&pngBuf, image.NewRGBA(image.Rect(0, 0, 8, 8))); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(old, pngBuf.Bytes(), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := ensureAvatar(7, url, cacheDir, legacyDir)
|
||||||
|
want := filepath.Join(cacheDir, name)
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("应搬到 %q,实际 %q", want, got)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(want)
|
||||||
|
if err != nil || !bytes.Equal(data, pngBuf.Bytes()) {
|
||||||
|
t.Fatalf("搬过来的内容不对:%v(%d 字节 vs %d 字节)", err, len(data), pngBuf.Len())
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(old); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("旧位置的文件应被搬走,实际还在:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已经在程序目录里的直接命中,不用管旧目录
|
||||||
|
if got := ensureAvatar(7, url, cacheDir, legacyDir); got != want {
|
||||||
|
t.Errorf("命中缓存应返回 %q,实际 %q", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 旧位置那份也是半截文件时,不能搬过来用:会一路传给 ffmpeg 当输入
|
||||||
|
func TestEnsureAvatarRejectsBrokenLegacyCache(t *testing.T) {
|
||||||
|
withJobFlags(t, true, false, false)
|
||||||
|
root := t.TempDir()
|
||||||
|
cacheDir := filepath.Join(root, "program", avatarCacheDir)
|
||||||
|
legacyDir := filepath.Join(root, "record", avatarCacheDir)
|
||||||
|
if err := os.MkdirAll(legacyDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
url := "http://127.0.0.1:9/never.png"
|
||||||
|
old := filepath.Join(legacyDir, avatarCacheName(8, url))
|
||||||
|
if err := os.WriteFile(old, []byte("这根本不是 PNG"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连不通的地址 + 坏缓存 → 拿不到头像,但绝不能把坏文件留在缓存目录里
|
||||||
|
if got := ensureAvatar(8, url, cacheDir); got != "" {
|
||||||
|
t.Fatalf("坏缓存不该被采用,实际返回 %q", got)
|
||||||
|
}
|
||||||
|
if validCachedAvatar(filepath.Join(cacheDir, avatarCacheName(8, url))) {
|
||||||
|
t.Fatal("坏文件不该出现在缓存目录里")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 头像下载的容量上限 ====================
|
||||||
|
// CDN 的响应大小和图片尺寸都是外部输入,必须有上限。
|
||||||
|
// 改之前 downloadAvatar 用的是裸 io.ReadAll:实测一个头像就能吃掉 512MB 内存。
|
||||||
|
|
||||||
|
// 异常大的响应必须在读完之前就被截住,不能整份读进内存
|
||||||
|
func TestDownloadAvatarCapsResponseBody(t *testing.T) {
|
||||||
|
var wanted int64 = avatarMaxBytes + 8<<20 // 比上限多 8MB
|
||||||
|
var served int64
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
buf := make([]byte, 1<<20)
|
||||||
|
for atomic.AddInt64(&served, int64(len(buf))) < wanted {
|
||||||
|
if _, err := w.Write(buf); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
out := filepath.Join(t.TempDir(), "a.png")
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- downloadAvatar(1, srv.URL, out) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("超过体积上限的头像应当报错,实际下载成功了")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "超过") {
|
||||||
|
t.Errorf("报错要说清是体积超限,实际:%v", err)
|
||||||
|
}
|
||||||
|
// 客户端只读到上限就撒手,服务端这边根本写不完整个响应。
|
||||||
|
// 反过来,如果 served 达到了 wanted,说明整份都被读进内存了 —— 限制没生效
|
||||||
|
if got := atomic.LoadInt64(&served); got >= wanted {
|
||||||
|
t.Errorf("整份响应都被读完了(%.1f MB),说明没有截断(上限 %d MB)",
|
||||||
|
float64(got)/1048576, avatarMaxBytes>>20)
|
||||||
|
}
|
||||||
|
t.Logf(" 已接收 %.1f MB 时就被截住(上限 %d MB)",
|
||||||
|
float64(atomic.LoadInt64(&served))/1048576, avatarMaxBytes>>20)
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
t.Fatalf("超过 30 秒还没结束,限制没生效(已接收 %.1f MB)",
|
||||||
|
float64(atomic.LoadInt64(&served))/1048576)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(out); err == nil {
|
||||||
|
t.Error("下载失败不该留下文件")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常的头像是能下下来的:上限不能误伤
|
||||||
|
func TestDownloadAvatarAcceptsNormalSize(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 96, 96))); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
_, _ = w.Write(buf.Bytes())
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
out := filepath.Join(t.TempDir(), "ok.png")
|
||||||
|
if err := downloadAvatar(1, srv.URL, out); err != nil {
|
||||||
|
t.Fatalf("正常大小的头像应当能下载,实际报错:%v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(out); err != nil {
|
||||||
|
t.Errorf("下载成功却没生成文件:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 压缩炸弹:几十 KB 的图可以解压出上亿像素,解码后必须再检查一次尺寸
|
||||||
|
func TestDecodeAvatarCapsPixels(t *testing.T) {
|
||||||
|
side := 3000 // 900 万像素,刚过 2048x2048 的上限
|
||||||
|
big := image.NewGray(image.Rect(0, 0, side, side))
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, big); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := decodeAvatar(buf.Bytes(), "image/png"); err == nil {
|
||||||
|
t.Fatalf("%dx%d 的头像应当被尺寸上限拦住", side, side)
|
||||||
|
} else if !strings.Contains(err.Error(), "超出上限") {
|
||||||
|
t.Errorf("报错要说清是尺寸超限,实际:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 常见的头像尺寸不受影响
|
||||||
|
ok := image.NewRGBA(image.Rect(0, 0, 300, 300))
|
||||||
|
var b2 bytes.Buffer
|
||||||
|
if err := png.Encode(&b2, ok); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := decodeAvatar(b2.Bytes(), "image/png"); err != nil {
|
||||||
|
t.Errorf("300x300 的普通头像不该被拦:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+374
@@ -0,0 +1,374 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 密集弹幕:每 0.02 秒一条,10 秒停留窗口里能塞进 500 条。
|
||||||
|
// 块尾扩张原来没有上限,会把窗口里的弹幕几乎全并进同一块
|
||||||
|
func denseRecords(n int) []danmuRecord {
|
||||||
|
records := make([]danmuRecord, 0, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
records = append(records, danmuRecord{
|
||||||
|
Time: float64(i) * 0.02,
|
||||||
|
UID: int64(i%5 + 1),
|
||||||
|
Name: "密集测试",
|
||||||
|
Content: "弹幕内容",
|
||||||
|
Lane: i % 10,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单块条数必须被硬上限挡住(续画 + 本块新出现的条数一起算),否则 -filter_complex
|
||||||
|
// 会撑爆命令行长度。
|
||||||
|
//
|
||||||
|
// 「新出现」的那些条按顺序拼起来必须正好是原始列表:续画只是同一批弹幕在后面几块里
|
||||||
|
// 接着画,不能算成新弹幕,更不能有哪一条一次都没算成新出现(那就整条丢了)
|
||||||
|
func TestSplitDanmuChunksCapsBlockSize(t *testing.T) {
|
||||||
|
records := denseRecords(600)
|
||||||
|
chunks := splitDanmuChunks(records, 0, 0)
|
||||||
|
if len(chunks) < 2 {
|
||||||
|
t.Fatalf("这么多弹幕应当分成多块,实际 %d 块", len(chunks))
|
||||||
|
}
|
||||||
|
fresh := make([]danmuRecord, 0, len(records))
|
||||||
|
for i, ch := range chunks {
|
||||||
|
if n := len(ch.records); n > maxDanmuPerChunkHard {
|
||||||
|
t.Fatalf("第 %d 块有 %d 条弹幕,超过硬上限 %d", i+1, n, maxDanmuPerChunkHard)
|
||||||
|
}
|
||||||
|
if ch.carried < 0 || ch.carried > len(ch.records) {
|
||||||
|
t.Fatalf("第 %d 块的 carried=%d 越界(这一块共 %d 条)", i+1, ch.carried, len(ch.records))
|
||||||
|
}
|
||||||
|
fresh = append(fresh, ch.records[ch.carried:]...)
|
||||||
|
}
|
||||||
|
if len(fresh) != len(records) {
|
||||||
|
t.Fatalf("新出现的弹幕总数应当是 %d,实际 %d", len(records), len(fresh))
|
||||||
|
}
|
||||||
|
for i := range records {
|
||||||
|
if fresh[i].Time != records[i].Time || fresh[i].Content != records[i].Content {
|
||||||
|
t.Fatalf("第 %d 条新弹幕对不上:拿到 %+v,期望 %+v", i, fresh[i], records[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一条弹幕停留 10 秒,块边界经常从它中间切过去:分块器必须把它「续画」到后面几块里。
|
||||||
|
// 不续画的话每个块头屏幕上只剩刚出现的那几条,要 10 秒才重新填满 —— 观众看到的就是
|
||||||
|
// 「弹幕突然加速冲过去,然后一段时间屏幕上什么都没有」
|
||||||
|
func TestSplitDanmuChunksCarriesOnScreenDanmu(t *testing.T) {
|
||||||
|
records := denseRecords(600) // 每 0.02 秒一条,10 秒窗口里同时有 500 条
|
||||||
|
chunks := splitDanmuChunks(records, 0, 0)
|
||||||
|
|
||||||
|
carriedTotal, fresh := 0, 0
|
||||||
|
for i, ch := range chunks {
|
||||||
|
carriedTotal += ch.carried
|
||||||
|
fresh += len(ch.records) - ch.carried
|
||||||
|
for _, r := range ch.records[:ch.carried] {
|
||||||
|
// 续画只能是「上几块里已经出现过、此刻还在屏上」的那些
|
||||||
|
if r.Time >= ch.start {
|
||||||
|
t.Fatalf("第 %d 块(起点 %.3f)把时间 %.3f 的弹幕当成了续画,它不是上一块留下来的",
|
||||||
|
i+1, ch.start, r.Time)
|
||||||
|
}
|
||||||
|
if r.Time < ch.start-danmuShowSeconds {
|
||||||
|
t.Fatalf("第 %d 块(起点 %.3f)续画了时间 %.3f 的弹幕,它早就滚出屏了",
|
||||||
|
i+1, ch.start, r.Time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if carriedTotal == 0 {
|
||||||
|
t.Fatal("这么密的弹幕不可能完全没有需要续画的块,续画逻辑没生效")
|
||||||
|
}
|
||||||
|
if fresh != len(records) {
|
||||||
|
t.Fatalf("新出现的弹幕数不对:%d,期望 %d", fresh, len(records))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 块与块必须首尾相接,而且每条弹幕的 10 秒停留窗口要在所有块里被完整画满:
|
||||||
|
// 中间只要有缺口,观众就会看到那条弹幕闪一下又消失
|
||||||
|
func TestSplitDanmuChunksCoversEveryShowWindow(t *testing.T) {
|
||||||
|
// 每 0.5 秒一条:10 秒窗口里同时有 20 条,续画远放得下,不会触发「挤掉最早的几条」
|
||||||
|
records := make([]danmuRecord, 600)
|
||||||
|
for i := range records {
|
||||||
|
records[i] = danmuRecord{Time: float64(i) * 0.5, UID: 1, Name: "甲", Content: "弹幕", Lane: i % 10}
|
||||||
|
}
|
||||||
|
chunks := splitDanmuChunks(records, 0, 0)
|
||||||
|
|
||||||
|
for i := 1; i < len(chunks); i++ {
|
||||||
|
if chunks[i].start != chunks[i-1].end {
|
||||||
|
t.Fatalf("第 %d 块和第 %d 块之间有空隙:%.3f -> %.3f",
|
||||||
|
i, i+1, chunks[i-1].end, chunks[i].start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range records {
|
||||||
|
covered := r.Time
|
||||||
|
for _, ch := range chunks {
|
||||||
|
vs, ve, ok := danmuVisibleInChunk(r, 0, ch)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if vs > covered+1e-9 {
|
||||||
|
t.Fatalf("时间 %.3f 的弹幕在 %.3f~%.3f 之间没被画出来(前后两块没接上)",
|
||||||
|
r.Time, covered, vs)
|
||||||
|
}
|
||||||
|
if ve > covered {
|
||||||
|
covered = ve
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if covered < r.Time+danmuShowSeconds-1e-9 {
|
||||||
|
t.Fatalf("时间 %.3f 的弹幕只画到 %.3f,10 秒停留窗口没画满", r.Time, covered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一时刻挤了一堆弹幕(时间戳重复)时块尾会和块头重合,那种块渲染出来一帧都没有,
|
||||||
|
// 拼进成片就是一段黑屏/缺帧
|
||||||
|
func TestSplitDanmuChunksNeverProducesEmptySpan(t *testing.T) {
|
||||||
|
records := make([]danmuRecord, 0, 400)
|
||||||
|
for i := 0; i < 400; i++ {
|
||||||
|
records = append(records, danmuRecord{
|
||||||
|
Time: 5, UID: 1, Name: "甲", Content: "同一时刻", Lane: i % 10, Width: 100,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
chunks := splitDanmuChunks(records, 0, 0)
|
||||||
|
if len(chunks) < 2 {
|
||||||
|
t.Fatalf("400 条应当分成多块,实际 %d 块", len(chunks))
|
||||||
|
}
|
||||||
|
for i, ch := range chunks {
|
||||||
|
if math.IsInf(ch.end, 1) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch.end <= ch.start {
|
||||||
|
t.Fatalf("第 %d 块的时间范围是空的(%.3f ~ %.3f),渲染出来一帧都没有",
|
||||||
|
i+1, ch.start, ch.end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退成「只合并第一个分段」时,used 必须只剩第一段:
|
||||||
|
// 自动合并收尾按 used 删源视频,返回全部就等于把没进成片的后半场删掉
|
||||||
|
func TestPrepareForMergeOnlyMarksUsedSegments(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
a := filepath.Join(dir, "a.mp4")
|
||||||
|
b := filepath.Join(dir, "b.mp4")
|
||||||
|
for _, p := range []string{a, b} {
|
||||||
|
if err := os.WriteFile(p, []byte("x"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsonl := filepath.Join(dir, "a.jsonl")
|
||||||
|
if err := os.WriteFile(jsonl, []byte("{}\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用一个不存在的 ffmpeg:probeDuration 拿不到时长,触发「只合并第一个分段」的回退
|
||||||
|
segs := []recordSegment{{file: a, startNs: 0}, {file: b, startNs: int64(time.Second)}}
|
||||||
|
video, _, _, used, cleanup, err := prepareForMerge(filepath.Join(dir, "没有这个-ffmpeg.exe"), segs, jsonl, 0)
|
||||||
|
defer cleanup()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("回退不该报错:%v", err)
|
||||||
|
}
|
||||||
|
if video != a {
|
||||||
|
t.Fatalf("回退后应当合并第一段,实际 %s", video)
|
||||||
|
}
|
||||||
|
if len(used) != 1 || used[0] != a {
|
||||||
|
t.Fatalf("回退成只合并第一段时 used 应只含第一段,实际 %v", used)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接改全局任务状态:测试里只碰 stopped 这一个字段,用完恢复
|
||||||
|
func setJobStopped(t *testing.T, v bool) {
|
||||||
|
t.Helper()
|
||||||
|
job.Lock()
|
||||||
|
prev := job.stopped
|
||||||
|
job.stopped = v
|
||||||
|
job.Unlock()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
job.Lock()
|
||||||
|
job.stopped = prev
|
||||||
|
job.Unlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 现场生成一个 1 秒的小视频分段
|
||||||
|
func makeTinyVideo(t *testing.T, ffmpegFile, path, color string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := runFFmpegWith(ffmpegFile, []string{
|
||||||
|
"-y", "-f", "lavfi", "-i", "color=c=" + color + ":s=160x120:d=1:r=10",
|
||||||
|
"-c:v", "libx264", "-pix_fmt", "yuv420p", path,
|
||||||
|
}, filepath.Dir(path), nil); err != nil {
|
||||||
|
t.Skipf("生成测试分段失败,跳过:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多段都拿到时长、成功拼接时,used 应当是全部分段(原来的删除行为不变)
|
||||||
|
func TestPrepareForMergeMarksAllSegmentsWhenJoined(t *testing.T) {
|
||||||
|
ffmpegFile := getFFmpeg()
|
||||||
|
if ffmpegFile == "" {
|
||||||
|
t.Skip("找不到 ffmpeg,跳过")
|
||||||
|
}
|
||||||
|
setJobStopped(t, false)
|
||||||
|
dir := t.TempDir()
|
||||||
|
a, b := filepath.Join(dir, "a.mp4"), filepath.Join(dir, "b.mp4")
|
||||||
|
makeTinyVideo(t, ffmpegFile, a, "red")
|
||||||
|
makeTinyVideo(t, ffmpegFile, b, "blue")
|
||||||
|
jsonl := filepath.Join(dir, "a.jsonl")
|
||||||
|
if err := os.WriteFile(jsonl, []byte(`{"playResX": 160, "playResY": 120, "fontSize": 20, "startTime": 0}`+"\n"+
|
||||||
|
`{"t": 0.2, "uid": 1, "name": "n", "avatar": "", "content": "c", "lane": 0, "width": 0}`+"\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
segs := []recordSegment{{file: a, startNs: 0}, {file: b, startNs: int64(time.Second)}}
|
||||||
|
video, out, _, used, cleanup, err := prepareForMerge(ffmpegFile, segs, jsonl, 0)
|
||||||
|
defer cleanup()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("拼接两个正常分段不该报错:%v", err)
|
||||||
|
}
|
||||||
|
if video != filepath.Join(dir, chunkTempPrefix+"joined.mp4") {
|
||||||
|
t.Fatalf("应当用拼接后的文件合并,实际 %s", video)
|
||||||
|
}
|
||||||
|
if len(used) != 2 {
|
||||||
|
t.Fatalf("拼接成功时 used 应包含全部分段,实际 %v", used)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(out); err != nil {
|
||||||
|
t.Fatalf("重映射的弹幕文件应当存在:%v", err)
|
||||||
|
}
|
||||||
|
// cleanup 必须把中间文件收干净
|
||||||
|
cleanup()
|
||||||
|
if _, err := os.Stat(video); err == nil {
|
||||||
|
t.Fatalf("清理后拼接出来的中间视频还在:%s", video)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(out); err == nil {
|
||||||
|
t.Fatalf("清理后重映射的弹幕文件还在:%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拼接中途被停止:要报成 errJobStopped,并且把拼到一半的中间文件删掉
|
||||||
|
// (留在录播目录里既占几 GB 又带 chunkTempPrefix,界面文件列表里根本看不见)
|
||||||
|
func TestPrepareForMergeDropsJoinedWhenStopped(t *testing.T) {
|
||||||
|
ffmpegFile := getFFmpeg()
|
||||||
|
if ffmpegFile == "" {
|
||||||
|
t.Skip("找不到 ffmpeg,跳过")
|
||||||
|
}
|
||||||
|
setJobStopped(t, false)
|
||||||
|
dir := t.TempDir()
|
||||||
|
a, b := filepath.Join(dir, "a.mp4"), filepath.Join(dir, "b.mp4")
|
||||||
|
makeTinyVideo(t, ffmpegFile, a, "red")
|
||||||
|
makeTinyVideo(t, ffmpegFile, b, "blue")
|
||||||
|
jsonl := filepath.Join(dir, "a.jsonl")
|
||||||
|
if err := os.WriteFile(jsonl, []byte("{}\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
joined := filepath.Join(dir, chunkTempPrefix+"joined.mp4")
|
||||||
|
if err := os.WriteFile(joined, []byte("拼到一半的内容"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
setJobStopped(t, true)
|
||||||
|
|
||||||
|
segs := []recordSegment{{file: a, startNs: 0}, {file: b, startNs: int64(time.Second)}}
|
||||||
|
_, _, _, used, cleanup, err := prepareForMerge(ffmpegFile, segs, jsonl, 0)
|
||||||
|
defer cleanup()
|
||||||
|
if !errors.Is(err, errJobStopped) {
|
||||||
|
t.Fatalf("被停止时应当返回 errJobStopped,实际 %v", err)
|
||||||
|
}
|
||||||
|
if len(used) != 0 {
|
||||||
|
t.Fatalf("被停止时不该把任何分段算作已合并,实际 %v", used)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(joined); err == nil {
|
||||||
|
t.Fatalf("停止后拼到一半的中间文件应当被删掉:%s", joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 任务处于「已请求停止」时,拼接失败要报成 errJobStopped,而不是「拼接失败」
|
||||||
|
func TestConcatChunksReportsStopped(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
f := filepath.Join(dir, "in.mp4")
|
||||||
|
if err := os.WriteFile(f, []byte("x"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out := filepath.Join(dir, "out.mp4")
|
||||||
|
|
||||||
|
setJobStopped(t, true)
|
||||||
|
|
||||||
|
err := concatChunks(filepath.Join(dir, "没有这个-ffmpeg.exe"), []string{f}, out, dir, nil)
|
||||||
|
if !errors.Is(err, errJobStopped) {
|
||||||
|
t.Fatalf("停止状态下拼接失败应当返回 errJobStopped,实际 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有停止请求时,拼接失败就是普通错误,别报成「已停止」
|
||||||
|
func TestConcatChunksReportsFailureWhenNotStopped(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
f := filepath.Join(dir, "in.mp4")
|
||||||
|
if err := os.WriteFile(f, []byte("x"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out := filepath.Join(dir, "out.mp4")
|
||||||
|
|
||||||
|
setJobStopped(t, false)
|
||||||
|
|
||||||
|
err := concatChunks(filepath.Join(dir, "没有这个-ffmpeg.exe"), []string{f}, out, dir, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("拼接应当失败")
|
||||||
|
}
|
||||||
|
if errors.Is(err, errJobStopped) {
|
||||||
|
t.Fatalf("没有停止请求时不该报成已停止:%v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "拼接") {
|
||||||
|
t.Fatalf("错误信息应当说明是拼接失败,实际 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定性失败不该白白重试三次
|
||||||
|
func TestRunThriceSkipsNoRetry(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
err := runThrice(func() error {
|
||||||
|
calls++
|
||||||
|
return noRetry(errors.New("确定性失败"))
|
||||||
|
})
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatalf("标记为不重试的错误应当只尝试一次,实际 %d 次", calls)
|
||||||
|
}
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "确定性失败") {
|
||||||
|
t.Fatalf("错误应当原样返回,实际 %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "运行三次") {
|
||||||
|
t.Fatalf("不该被包装成「运行三次都出现错误」:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通错误仍然照旧重试
|
||||||
|
func TestRunThriceStillRetriesNormalError(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
err := runThrice(func() error {
|
||||||
|
calls++
|
||||||
|
if calls == 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("暂时失败")
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("第二次成功时不该报错:%v", err)
|
||||||
|
}
|
||||||
|
if calls != 2 {
|
||||||
|
t.Fatalf("普通错误应当重试,实际只尝试了 %d 次", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交给 exec 的 ffmpeg 路径必须是绝对路径:
|
||||||
|
// Windows 上 exec 会直接拒绝执行「相对当前目录」找到的可执行文件
|
||||||
|
func TestGetFFmpegReturnsAbsPath(t *testing.T) {
|
||||||
|
p := getFFmpeg()
|
||||||
|
if p == "" {
|
||||||
|
t.Skip("本机找不到 ffmpeg,跳过")
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(p) {
|
||||||
|
t.Fatalf("getFFmpeg 应当返回绝对路径,实际 %q", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 按最坏情况(每条弹幕一个不同用户、每个都带头像、文字宽度顶满整屏)渲染一块,
|
||||||
|
// 返回这一块的命令行长度。长度从 renderChunk 自己的自检错误里读出来;
|
||||||
|
// 没触发自检就返回 (0, false)。
|
||||||
|
//
|
||||||
|
// 头像路径必须按 renderChunk 真正会拿到的形态造 —— 预缩放目录里的短相对路径
|
||||||
|
// (见 prepareAvatarInputs),不是 avatar_cache 里的长绝对路径;用后者量出来的是
|
||||||
|
// 一个早就不存在的旧版长度,会把上限算得过分保守。
|
||||||
|
//
|
||||||
|
// 线上的那次事故就是这里没守住:单块 200 条时实测 48288 字符,远超 Windows 的 32767,
|
||||||
|
// 而 exec 只回一句 "The filename or extension is too long",看不出跟弹幕数量有关。
|
||||||
|
func chunkCommandLenProbe(t *testing.T, n int) (length int, over bool) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
avatars := make(map[int64]string, n)
|
||||||
|
records := make([]danmuRecord, 0, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
uid := int64(1000000 + i)
|
||||||
|
avatars[uid] = chunkAvatarDir + "/" + fmt.Sprintf("%d.png", i+1)
|
||||||
|
records = append(records, danmuRecord{
|
||||||
|
Time: float64(i) * 0.05, UID: uid, Name: "观众甲",
|
||||||
|
Content: "这条弹幕稍微长一点测试宽度", Lane: i % 100, Width: 1920,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ctx := &burnContext{
|
||||||
|
ffmpegFile: filepath.Join(dir, "没有这个-ffmpeg.exe"), // 自检在 exec 之前,跑不到它
|
||||||
|
outFile: filepath.Join(dir, "out.mp4"),
|
||||||
|
meta: danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 42},
|
||||||
|
avatars: avatars,
|
||||||
|
dir: dir,
|
||||||
|
}
|
||||||
|
err := ctx.renderChunk(danmuChunk{start: 0, end: 60, records: records}, "chunk.ass", ctx.outFile, false, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("用不存在的 ffmpeg 路径不该成功")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "命令过长") {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if _, scanErr := fmt.Sscanf(err.Error(), "ffmpeg 命令过长(%d 字符", &length); scanErr != nil {
|
||||||
|
t.Fatalf("没能从错误里读出长度:%v / %s", scanErr, err)
|
||||||
|
}
|
||||||
|
return length, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按硬上限切出来的块,在最坏情况下也不能顶破命令行上限
|
||||||
|
func TestChunkCommandLengthUnderLimit(t *testing.T) {
|
||||||
|
if length, over := chunkCommandLenProbe(t, maxDanmuPerChunkHard); over {
|
||||||
|
t.Fatalf("按硬上限 %d 条渲染,命令长达 %d 字符,已经超过安全值 %d",
|
||||||
|
maxDanmuPerChunkHard, length, maxCommandLineLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 块大到一定程度必须被自检拦住,并报出「多少条弹幕、多少个头像」这种能定位的信息
|
||||||
|
func TestRenderChunkReportsTooLongCommand(t *testing.T) {
|
||||||
|
length, over := chunkCommandLenProbe(t, 300)
|
||||||
|
if !over {
|
||||||
|
t.Fatal("300 条的最坏情况应当被自检拦住,否则交给 exec 只会报一句看不出原因的错")
|
||||||
|
}
|
||||||
|
if length <= maxCommandLineLen {
|
||||||
|
t.Fatalf("自检报出的长度 %d 应当大于阈值 %d", length, maxCommandLineLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 硬上限本身要留出余量:真跑一遍「上限条数 + 最坏情况」,而不是拿一个会过期的
|
||||||
|
// 单条估算值乘一乘。换了头像方案(绝对路径 → 预缩放短路径)、滤镜写法或
|
||||||
|
// 单条 overlay 的表达式以后,这个用例会跟着一起变,不会悄悄失效
|
||||||
|
func TestChunkHardLimitLeavesHeadroom(t *testing.T) {
|
||||||
|
if length, over := chunkCommandLenProbe(t, maxDanmuPerChunkHard); over {
|
||||||
|
t.Fatalf("硬上限 %d 条的最坏情况实测 %d 字符,已经超过安全值 %d",
|
||||||
|
maxDanmuPerChunkHard, length, maxCommandLineLen)
|
||||||
|
}
|
||||||
|
// 再往上加一成也不该顶破安全值,防止上限被悄悄调高到刚好卡线
|
||||||
|
headroom := maxDanmuPerChunkHard / 10
|
||||||
|
if length, over := chunkCommandLenProbe(t, maxDanmuPerChunkHard+headroom); over {
|
||||||
|
t.Fatalf("上限再高 %d 条(共 %d 条)就顶破了安全值 %d 字符,余量太小",
|
||||||
|
headroom, maxDanmuPerChunkHard+headroom, length)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -29,7 +30,59 @@ type streamer struct {
|
|||||||
Name string `json:"name"` // 主播名字
|
Name string `json:"name"` // 主播名字
|
||||||
Danmu bool `json:"-"` // 是否录制直播弹幕
|
Danmu bool `json:"-"` // 是否录制直播弹幕
|
||||||
Record bool `json:"-"` // 是否录制直播视频
|
Record bool `json:"-"` // 是否录制直播视频
|
||||||
Bitrate int `json:"-"` // 录制的直播源最高码率(Kbps),0 表示码率最高的直播源
|
QualityPref string `json:"-"` // 档位偏好:最高/第二高/最低/自定义(见 qualityPref* 常量)
|
||||||
|
Bitrate int `json:"-"` // 自定义档位时的码率上限(Kbps),只在 QualityPref 为 custom 时有意义
|
||||||
|
Qualities []qualityTier `json:"-"` // 上次探测到的码率档位,没开播时页面靠它显示对应关系
|
||||||
|
}
|
||||||
|
|
||||||
|
// 录制档位的偏好。档位名每次开播都可能不一样(不一定都有「蓝光 8M」),
|
||||||
|
// 所以按「实际档位里的第几档」来选,不开播也能先设好,开播后自动对应上去。
|
||||||
|
const (
|
||||||
|
qualityPrefTop = "top" // 最高档
|
||||||
|
qualityPrefSecond = "second" // 自上往下第二高(默认)
|
||||||
|
qualityPrefLowest = "lowest" // 最低档
|
||||||
|
qualityPrefCustom = "custom" // 自定义:不超过指定码率的最高档
|
||||||
|
qualityPrefDefault = qualityPrefSecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// 把档位偏好规整成合法值,空值或没见过的值一律当默认(第二高)
|
||||||
|
func normalizeQualityPref(pref string) string {
|
||||||
|
switch pref {
|
||||||
|
case qualityPrefTop, qualityPrefSecond, qualityPrefLowest, qualityPrefCustom:
|
||||||
|
return pref
|
||||||
|
}
|
||||||
|
return qualityPrefDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
// 老配置里没有 qualityPref,只有 bitrate:填了码率就是「自定义上限」,0 就是默认的第二高
|
||||||
|
func migrateQualityPref(pref string, bitrate int) string {
|
||||||
|
if pref == "" {
|
||||||
|
if bitrate > 0 {
|
||||||
|
return qualityPrefCustom
|
||||||
|
}
|
||||||
|
return qualityPrefDefault
|
||||||
|
}
|
||||||
|
return normalizeQualityPref(pref)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 档位偏好对应的中文说法,用于日志
|
||||||
|
func qualityPrefText(pref string) string {
|
||||||
|
switch normalizeQualityPref(pref) {
|
||||||
|
case qualityPrefTop:
|
||||||
|
return "最高档"
|
||||||
|
case qualityPrefLowest:
|
||||||
|
return "最低档"
|
||||||
|
case qualityPrefCustom:
|
||||||
|
return "自定义"
|
||||||
|
default:
|
||||||
|
return "第二高"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直播源的码率档位
|
||||||
|
type qualityTier struct {
|
||||||
|
Name string `json:"name"` // 档位名,如「蓝光4M」「高清」
|
||||||
|
Bitrate int `json:"bitrate"` // 档位码率(Kbps)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 单个主播的开关设置
|
// 单个主播的开关设置
|
||||||
@@ -37,7 +90,9 @@ type uidSetting struct {
|
|||||||
UID int `json:"uid"`
|
UID int `json:"uid"`
|
||||||
Danmu bool `json:"danmu"`
|
Danmu bool `json:"danmu"`
|
||||||
Record bool `json:"record"`
|
Record bool `json:"record"`
|
||||||
Bitrate int `json:"bitrate"` // 录制的直播源最高码率(Kbps),0 表示码率最高的直播源
|
QualityPref string `json:"qualityPref,omitempty"` // 档位偏好,留空按老规则从 bitrate 推(见 migrateQualityPref)
|
||||||
|
Bitrate int `json:"bitrate"` // 自定义档位时的码率上限(Kbps)
|
||||||
|
Qualities []qualityTier `json:"qualities,omitempty"` // 上次探测到的档位,缓存下来让没开播时也能显示对应关系
|
||||||
}
|
}
|
||||||
|
|
||||||
// uidSetting 列表,兼容旧的纯数字数组格式(旧格式默认弹幕和录播都开启)
|
// uidSetting 列表,兼容旧的纯数字数组格式(旧格式默认弹幕和录播都开启)
|
||||||
@@ -74,7 +129,7 @@ var configMu sync.Mutex
|
|||||||
// 设置数据
|
// 设置数据
|
||||||
type configData struct {
|
type configData struct {
|
||||||
UIDs uidList `json:"uids"` // 要录制的主播 uid 列表和各自的开关
|
UIDs uidList `json:"uids"` // 要录制的主播 uid 列表和各自的开关
|
||||||
Directory string `json:"directory"` // 弹幕文件录制结束后会被移动到该文件夹
|
Directory string `json:"directory"` // 录播和弹幕文件保存的根目录,留空用 -record 指定的文件夹
|
||||||
WebPort int `json:"webPort"` // web UI 的本地端口,为 0 时不启动 web UI
|
WebPort int `json:"webPort"` // web UI 的本地端口,为 0 时不启动 web UI
|
||||||
|
|
||||||
AutoMerge bool `json:"autoMerge"` // 直播结束后自动把弹幕层合并进录播
|
AutoMerge bool `json:"autoMerge"` // 直播结束后自动把弹幕层合并进录播
|
||||||
@@ -82,9 +137,12 @@ type configData struct {
|
|||||||
MergeDelDanmu bool `json:"mergeDelDanmu"` // 合并完成后删除弹幕文件
|
MergeDelDanmu bool `json:"mergeDelDanmu"` // 合并完成后删除弹幕文件
|
||||||
MergeOutputDir string `json:"mergeOutputDir"` // 合并完成后成片移动到该文件夹,留空不移动
|
MergeOutputDir string `json:"mergeOutputDir"` // 合并完成后成片移动到该文件夹,留空不移动
|
||||||
MergeBitrate int `json:"mergeBitrate"` // 合并成片的目标码率(Kbps),0 表示使用默认质量
|
MergeBitrate int `json:"mergeBitrate"` // 合并成片的目标码率(Kbps),0 表示使用默认质量
|
||||||
DanmuFontSize int `json:"danmuFontSize"` // 合并时弹幕文字的字号(头像随字号等比缩放),0 表示跟随弹幕文件
|
MergeFps int `json:"mergeFps"` // 合并成片的输出帧率,0 表示跟随源片(只在高于源片时生效,源片帧率不会被降下来)
|
||||||
|
DanmuFontSize int `json:"danmuFontSize"` // 合并时弹幕文字的字号(像素,头像随字号等比缩放),0 表示按画面分辨率自动
|
||||||
|
|
||||||
DefaultBitrate int `json:"defaultBitrate"` // 新添加主播的默认下载码率上限(Kbps),0 表示录制码率最高的直播源
|
// 新添加主播的默认录制档位(留空/非法按第二高)与自定义时的码率上限
|
||||||
|
DefaultQualityPref string `json:"defaultQualityPref,omitempty"`
|
||||||
|
DefaultBitrate int `json:"defaultBitrate"`
|
||||||
|
|
||||||
WebExternal bool `json:"webExternal"` // 是否允许外网访问 web UI,false 时只监听本机
|
WebExternal bool `json:"webExternal"` // 是否允许外网访问 web UI,false 时只监听本机
|
||||||
|
|
||||||
@@ -97,7 +155,8 @@ type configData struct {
|
|||||||
NotifyLiveOn bool `json:"notifyLiveOn"` // 开播时推送
|
NotifyLiveOn bool `json:"notifyLiveOn"` // 开播时推送
|
||||||
NotifyLiveOff bool `json:"notifyLiveOff"` // 下播时推送
|
NotifyLiveOff bool `json:"notifyLiveOff"` // 下播时推送
|
||||||
NotifyRecordStart bool `json:"notifyRecordStart"` // 开始录制直播视频时推送
|
NotifyRecordStart bool `json:"notifyRecordStart"` // 开始录制直播视频时推送
|
||||||
NotifyMergeDone bool `json:"notifyMergeDone"` // 自动合并完成时推送
|
NotifyMergeDone bool `json:"notifyMergeDone"` // 合并完成时推送
|
||||||
|
NotifyMergeFail bool `json:"notifyMergeFail"` // 合并失败时推送(用户手动停止不算失败,不推)
|
||||||
|
|
||||||
MeowEnabled bool `json:"meowEnabled"` // MeoW 推送的渠道开关
|
MeowEnabled bool `json:"meowEnabled"` // MeoW 推送的渠道开关
|
||||||
MeowNickname string `json:"meowNickname"` // MeoW 昵称,推送地址里的路径参数
|
MeowNickname string `json:"meowNickname"` // MeoW 昵称,推送地址里的路径参数
|
||||||
@@ -131,25 +190,29 @@ func saveRootDir() string {
|
|||||||
if config.Directory != "" {
|
if config.Directory != "" {
|
||||||
return absOrSelf(config.Directory)
|
return absOrSelf(config.Directory)
|
||||||
}
|
}
|
||||||
if recordDir == nil {
|
if recordDir != nil && *recordDir != "" {
|
||||||
return ""
|
|
||||||
}
|
|
||||||
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 {
|
func recordSaveDir() string {
|
||||||
return filepath.Join(saveRootDir(), config.RecordSubdir)
|
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 {
|
func mergeSaveDir() string {
|
||||||
if config.MergeOutputDir != "" {
|
if config.MergeOutputDir != "" {
|
||||||
@@ -167,6 +230,9 @@ const (
|
|||||||
// 录播视频默认的封装格式。原版(orzogc/acfunlive)用 config.Output 决定扩展名,默认就是 mp4
|
// 录播视频默认的封装格式。原版(orzogc/acfunlive)用 config.Output 决定扩展名,默认就是 mp4
|
||||||
const defaultRecordFormat = "mp4"
|
const defaultRecordFormat = "mp4"
|
||||||
|
|
||||||
|
// 合并输出帧率的上限。再高只会让渲染更慢,对弹幕流畅度没有意义
|
||||||
|
const maxMergeFps = 240
|
||||||
|
|
||||||
// 支持的录播封装格式。录制用的是 -c copy,只换容器不转码,所以这些都只是封装差别
|
// 支持的录播封装格式。录制用的是 -c copy,只换容器不转码,所以这些都只是封装差别
|
||||||
var recordFormats = map[string]struct{}{
|
var recordFormats = map[string]struct{}{
|
||||||
"mp4": {},
|
"mp4": {},
|
||||||
@@ -204,6 +270,7 @@ var config = configData{
|
|||||||
RecordSubdir: defaultRecordSubdir,
|
RecordSubdir: defaultRecordSubdir,
|
||||||
MergeSubdir: defaultMergeSubdir,
|
MergeSubdir: defaultMergeSubdir,
|
||||||
NotifyEnabled: true,
|
NotifyEnabled: true,
|
||||||
|
NotifyMergeFail: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Windows 下不能用作文件(夹)名的保留字
|
// Windows 下不能用作文件(夹)名的保留字
|
||||||
@@ -265,10 +332,11 @@ func loadConfig() {
|
|||||||
checkErr(err)
|
checkErr(err)
|
||||||
|
|
||||||
if json.Valid(data) {
|
if json.Valid(data) {
|
||||||
// 老设置文件里没有 notifyEnabled 这个字段,默认按「开启」处理,
|
// 老设置文件里没有 notifyEnabled / notifyMergeFail 这两个字段,默认按「开启」处理,
|
||||||
// 否则升级后已经配好的推送会被静默关掉。写在 Unmarshal 前面,
|
// 否则升级后已经配好的推送会被静默关掉。写在 Unmarshal 前面,
|
||||||
// 文件里显式写了 false 时仍会被覆盖成 false
|
// 文件里显式写了 false 时仍会被覆盖成 false
|
||||||
config.NotifyEnabled = true
|
config.NotifyEnabled = true
|
||||||
|
config.NotifyMergeFail = true
|
||||||
err = json.Unmarshal(data, &config)
|
err = json.Unmarshal(data, &config)
|
||||||
checkErr(err)
|
checkErr(err)
|
||||||
} else {
|
} else {
|
||||||
@@ -278,11 +346,18 @@ func loadConfig() {
|
|||||||
|
|
||||||
// 命令行合成模式(-danmuburn)也读取设置文件,让 ffmpeg 路径、成片目录等配置生效
|
// 命令行合成模式(-danmuburn)也读取设置文件,让 ffmpeg 路径、成片目录等配置生效
|
||||||
func loadConfigForDanmuburn() {
|
func loadConfigForDanmuburn() {
|
||||||
if *configDir == "" {
|
// 这条路径不走 initialize(),exeDir 得自己定:头像缓存放哪、ffmpeg 去哪找都看它。
|
||||||
|
// 漏掉的话 cacheDir 会变成「当前工作目录/avatar_cache」,换目录跑就会重新下一遍头像,
|
||||||
|
// 更糟的是旧版把头像绝对路径直接写进滤镜,拿到相对路径时 ffmpeg(工作目录是弹幕目录)
|
||||||
|
// 就找不到文件、整块渲染失败
|
||||||
|
if exeDir == "" {
|
||||||
if exePath, err := os.Executable(); err == nil {
|
if exePath, err := os.Executable(); err == nil {
|
||||||
*configDir = filepath.Dir(exePath)
|
exeDir = filepath.Dir(exePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if *configDir == "" {
|
||||||
|
*configDir = exeDir
|
||||||
|
}
|
||||||
if *configDir == "" {
|
if *configDir == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -329,6 +404,20 @@ func currentAuthToken() string {
|
|||||||
return config.AuthToken
|
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 里的配置
|
// 检查 config.json 里的配置
|
||||||
func checkConfig() {
|
func checkConfig() {
|
||||||
for _, u := range config.UIDs {
|
for _, u := range config.UIDs {
|
||||||
@@ -341,28 +430,26 @@ func checkConfig() {
|
|||||||
lPrintErr(configFile + "里的 webPort 必须为 0(不启动 web UI)或者大于 1023 且少于 65536")
|
lPrintErr(configFile + "里的 webPort 必须为 0(不启动 web UI)或者大于 1023 且少于 65536")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if config.Directory != "" {
|
// 目录不在(盘没插、路径拼错)要给人话提示然后退出,不能直接 panic:
|
||||||
info, err := os.Stat(config.Directory)
|
// 崩溃时文件系统风险未知,而且服务被托管时会被反复拉起来
|
||||||
checkErr(err)
|
if msg := checkDirField(config.Directory, configFile+"里的directory"); msg != "" {
|
||||||
if !info.IsDir() {
|
lPrintErr(msg)
|
||||||
lPrintErrf("%s里的directory必须是存在的文件夹:%s", configFile, config.Directory)
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
if msg := checkDirField(config.MergeOutputDir, configFile+"里的mergeOutputDir"); msg != "" {
|
||||||
if config.MergeOutputDir != "" {
|
lPrintErr(msg)
|
||||||
info, err := os.Stat(config.MergeOutputDir)
|
|
||||||
checkErr(err)
|
|
||||||
if !info.IsDir() {
|
|
||||||
lPrintErrf("%s里的mergeOutputDir必须是存在的文件夹:%s", configFile, config.MergeOutputDir)
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if config.MergeBitrate < 0 {
|
if config.MergeBitrate < 0 {
|
||||||
lPrintErrf("%s里的mergeBitrate不能小于 0", configFile)
|
lPrintErrf("%s里的mergeBitrate不能小于 0", configFile)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if config.DanmuFontSize < 0 {
|
if config.DanmuFontSize < 0 {
|
||||||
lPrintErrf("%s里的danmuFontSize不能小于 0(0 表示跟随弹幕文件)", configFile)
|
lPrintErrf("%s里的danmuFontSize不能小于 0(0 表示按画面分辨率自动)", configFile)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if config.MergeFps < 0 || config.MergeFps > maxMergeFps {
|
||||||
|
lPrintErrf("%s里的mergeFps只能在 0(跟随源片)到 %d 之间", configFile, maxMergeFps)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if config.DefaultBitrate < 0 {
|
if config.DefaultBitrate < 0 {
|
||||||
@@ -405,21 +492,29 @@ func checkConfig() {
|
|||||||
config.FFmpegPath = ""
|
config.FFmpegPath = ""
|
||||||
}
|
}
|
||||||
// 确保保存文件夹存在
|
// 确保保存文件夹存在
|
||||||
for _, d := range []string{recordingDir(), recordSaveDir(), mergeSaveDir()} {
|
for _, d := range []string{recordSaveDir(), mergeSaveDir()} {
|
||||||
if err := os.MkdirAll(d, 0755); err != nil {
|
if err := os.MkdirAll(d, 0755); err != nil {
|
||||||
lPrintErrf("创建文件夹 %s 失败:%v", d, err)
|
lPrintErrf("创建文件夹 %s 失败:%v", d, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 上次在合成途中被关掉的话,录播目录和成片目录里会留下临时文件
|
|
||||||
cleanTempFiles(recordSaveDir())
|
|
||||||
cleanTempFiles(mergeSaveDir())
|
|
||||||
for _, u := range config.UIDs {
|
for _, u := range config.UIDs {
|
||||||
if u.Bitrate < 0 {
|
if u.Bitrate < 0 {
|
||||||
lPrintErrf("%s里的uid %d的bitrate不能小于 0", configFile, u.UID)
|
lPrintErrf("%s里的uid %d的bitrate不能小于 0", configFile, u.UID)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 老配置没有 qualityPref,这里按 bitrate 推一个出来,不写盘(下次保存时自然带上)
|
||||||
|
upgradeQualityPrefs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清掉上次运行残留的合成临时文件,只在启动时调用一次。
|
||||||
|
// 不能放进 loadConfig:改设置(web UI 或直接改 config.json)会重新读取配置,
|
||||||
|
// 那样会把正在进行中的合并的临时文件(分块字幕、分块视频、先本地合成的成片)
|
||||||
|
// 一起删掉,合并当场失败。
|
||||||
|
func cleanLeftoverTempFiles() {
|
||||||
|
cleanTempFiles(recordSaveDir())
|
||||||
|
cleanTempFiles(mergeSaveDir())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据 config.UIDs 构建 streamers.crt,调用方需要自己处理 streamers 的锁
|
// 根据 config.UIDs 构建 streamers.crt,调用方需要自己处理 streamers 的锁
|
||||||
@@ -433,7 +528,9 @@ func buildStreamers() {
|
|||||||
if s, ok := streamers.crt[u.UID]; ok {
|
if s, ok := streamers.crt[u.UID]; ok {
|
||||||
s.Danmu = u.Danmu
|
s.Danmu = u.Danmu
|
||||||
s.Record = u.Record
|
s.Record = u.Record
|
||||||
|
s.QualityPref = u.QualityPref
|
||||||
s.Bitrate = u.Bitrate
|
s.Bitrate = u.Bitrate
|
||||||
|
s.Qualities = u.Qualities
|
||||||
news[u.UID] = s
|
news[u.UID] = s
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -441,11 +538,147 @@ func buildStreamers() {
|
|||||||
if name == "" {
|
if name == "" {
|
||||||
name = fmt.Sprintf("uid%d", u.UID)
|
name = fmt.Sprintf("uid%d", u.UID)
|
||||||
}
|
}
|
||||||
news[u.UID] = streamer{UID: u.UID, Name: name, Danmu: u.Danmu, Record: u.Record, Bitrate: u.Bitrate}
|
news[u.UID] = streamer{UID: u.UID, Name: name, Danmu: u.Danmu, Record: u.Record,
|
||||||
|
QualityPref: u.QualityPref, Bitrate: u.Bitrate, Qualities: u.Qualities}
|
||||||
}
|
}
|
||||||
streamers.crt = news
|
streamers.crt = news
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 老配置里没有 qualityPref(只有 bitrate),读进来时补一个,并把结果写回 config.UIDs,
|
||||||
|
// 这样运行时各处读到的偏好都是确定的,下次保存也就把新字段落了盘
|
||||||
|
func upgradeQualityPrefs() {
|
||||||
|
for i := range config.UIDs {
|
||||||
|
want := migrateQualityPref(config.UIDs[i].QualityPref, config.UIDs[i].Bitrate)
|
||||||
|
if config.UIDs[i].QualityPref != want {
|
||||||
|
config.UIDs[i].QualityPref = want
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if want := migrateQualityPref(config.DefaultQualityPref, config.DefaultBitrate); config.DefaultQualityPref != want {
|
||||||
|
config.DefaultQualityPref = want
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 档位按码率从高到低排(页面的下拉按这个顺序显示)
|
||||||
|
func sortQualitiesDesc(tiers []qualityTier) []qualityTier {
|
||||||
|
out := append([]qualityTier(nil), tiers...)
|
||||||
|
sort.SliceStable(out, func(i, j int) bool { return out[i].Bitrate > out[j].Bitrate })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没手动选过(第二高)时的默认档位:按码率自上往下取第二档;
|
||||||
|
// 只有一个档位就用它;没有档位返回 0
|
||||||
|
func defaultQualityBitrate(tiers []qualityTier) int {
|
||||||
|
desc := sortQualitiesDesc(tiers)
|
||||||
|
switch len(desc) {
|
||||||
|
case 0:
|
||||||
|
return 0
|
||||||
|
case 1:
|
||||||
|
return desc[0].Bitrate
|
||||||
|
default:
|
||||||
|
return desc[1].Bitrate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 某个码率对应的档位名,匹配不上返回空
|
||||||
|
func qualityNameFor(tiers []qualityTier, bitrate int) string {
|
||||||
|
for _, t := range sortQualitiesDesc(tiers) {
|
||||||
|
if t.Bitrate == bitrate {
|
||||||
|
return t.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 两个档位列表是不是一样(避免每次都写配置)
|
||||||
|
func sameQualities(a, b []qualityTier) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
da, db := sortQualitiesDesc(a), sortQualitiesDesc(b)
|
||||||
|
for i := range da {
|
||||||
|
if da[i] != db[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记下某个主播当前的码率档位(离线时页面靠它显示下拉)。
|
||||||
|
// 不动用户选的码率:0 表示「自动、按实际档位取第二高」,只在真正取流时解析,
|
||||||
|
// 这样主播换了档位表也能跟着走,而不是把某个码率钉死。返回是否改动了配置。
|
||||||
|
func setStreamerQualities(uid int, tiers []qualityTier) bool {
|
||||||
|
if uid <= 0 || len(tiers) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
streamers.Lock()
|
||||||
|
for i := range config.UIDs {
|
||||||
|
if config.UIDs[i].UID != uid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !sameQualities(config.UIDs[i].Qualities, tiers) {
|
||||||
|
config.UIDs[i].Qualities = tiers
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if s, ok := streamers.crt[uid]; ok {
|
||||||
|
s.Qualities = config.UIDs[i].Qualities
|
||||||
|
streamers.crt[uid] = s
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
streamers.Unlock()
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按偏好决定实际用哪个档位,返回档位码率:
|
||||||
|
// 最高/第二高/最低按本次读到的档位排序取;自定义取不超过 custom 的最高档,都比它高时退回最低档。
|
||||||
|
// 档位列表为空时返回 0(自定义时返回 custom),交给调用方按老规矩取最高档。
|
||||||
|
func resolveQualityBitrate(tiers []qualityTier, pref string, custom int) int {
|
||||||
|
desc := sortQualitiesDesc(tiers)
|
||||||
|
if len(desc) == 0 {
|
||||||
|
if normalizeQualityPref(pref) == qualityPrefCustom {
|
||||||
|
return custom
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch normalizeQualityPref(pref) {
|
||||||
|
case qualityPrefTop:
|
||||||
|
return desc[0].Bitrate
|
||||||
|
case qualityPrefLowest:
|
||||||
|
return desc[len(desc)-1].Bitrate
|
||||||
|
case qualityPrefCustom:
|
||||||
|
for _, t := range desc {
|
||||||
|
if t.Bitrate <= custom {
|
||||||
|
return t.Bitrate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return desc[len(desc)-1].Bitrate
|
||||||
|
default:
|
||||||
|
return defaultQualityBitrate(desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从文件(成片或录播)名里猜是哪个主播:名字出现在文件名里、且只有一个能对上时才认,
|
||||||
|
// 认不出来返回 0。手动合并的推送靠它带上主播名,猜不准就退回通用文案。
|
||||||
|
func guessUIDByFileName(name string) int {
|
||||||
|
if name == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
base := filepath.Base(name)
|
||||||
|
matched := 0
|
||||||
|
for _, s := range append(uidList(nil), config.UIDs...) {
|
||||||
|
n := nameForLog(s.UID)
|
||||||
|
if len([]rune(n)) < 2 || !strings.Contains(base, n) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if matched != 0 {
|
||||||
|
return 0 // 多个主播都对得上,不猜了
|
||||||
|
}
|
||||||
|
matched = s.UID
|
||||||
|
}
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
// 保存 config.json,写入串行化并原子替换,避免并发写坏文件或读到写了一半的内容
|
// 保存 config.json,写入串行化并原子替换,避免并发写坏文件或读到写了一半的内容
|
||||||
func saveConfig() {
|
func saveConfig() {
|
||||||
configMu.Lock()
|
configMu.Lock()
|
||||||
@@ -555,7 +788,8 @@ func setStreamerFlags(uid int, danmu, record bool) bool {
|
|||||||
lPrintWarnf("不存在 uid 为%d的用户", uid)
|
lPrintWarnf("不存在 uid 为%d的用户", uid)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
config.UIDs = append(config.UIDs, uidSetting{UID: uid, Danmu: danmu, Record: record, Bitrate: config.DefaultBitrate})
|
config.UIDs = append(config.UIDs, uidSetting{UID: uid, Danmu: danmu, Record: record,
|
||||||
|
QualityPref: normalizeQualityPref(config.DefaultQualityPref), Bitrate: config.DefaultBitrate})
|
||||||
}
|
}
|
||||||
// 同步 streamers.crt 里的开关
|
// 同步 streamers.crt 里的开关
|
||||||
if s, ok := streamers.crt[uid]; ok {
|
if s, ok := streamers.crt[uid]; ok {
|
||||||
@@ -582,12 +816,14 @@ func setStreamerFlags(uid int, danmu, record bool) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置指定主播的录播码率上限(Kbps,0 为码率最高的直播源)并保存,正在录制时自动重启录制让新码率生效
|
// 改某个主播的录制档位偏好(pref 见 qualityPref* 常量,custom 是自定义时的码率上限)。
|
||||||
func setBitrate(uid, bitrate int) bool {
|
// 正在录制的会把这一段停掉重开,让新档位立刻生效
|
||||||
if uid <= 0 || bitrate < 0 {
|
func setQuality(uid int, pref string, custom int) bool {
|
||||||
lPrintWarn("uid 必须大于 0,bitrate 不能小于 0")
|
if uid <= 0 || custom < 0 {
|
||||||
|
lPrintWarn("uid 必须大于 0,自定义码率不能小于 0")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
pref = normalizeQualityPref(pref)
|
||||||
|
|
||||||
wasRecording := false
|
wasRecording := false
|
||||||
if infoList, ok := getLiveInfoByUID(uid); ok {
|
if infoList, ok := getLiveInfoByUID(uid); ok {
|
||||||
@@ -602,14 +838,16 @@ func setBitrate(uid, bitrate int) bool {
|
|||||||
found := false
|
found := false
|
||||||
for i, u := range config.UIDs {
|
for i, u := range config.UIDs {
|
||||||
if u.UID == uid {
|
if u.UID == uid {
|
||||||
config.UIDs[i].Bitrate = bitrate
|
config.UIDs[i].QualityPref = pref
|
||||||
|
config.UIDs[i].Bitrate = custom
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if found {
|
if found {
|
||||||
if s, ok := streamers.crt[uid]; ok {
|
if s, ok := streamers.crt[uid]; ok {
|
||||||
s.Bitrate = bitrate
|
s.QualityPref = pref
|
||||||
|
s.Bitrate = custom
|
||||||
streamers.crt[uid] = s
|
streamers.crt[uid] = s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -621,15 +859,23 @@ func setBitrate(uid, bitrate int) bool {
|
|||||||
|
|
||||||
saveConfig()
|
saveConfig()
|
||||||
|
|
||||||
// 码率只对新分段生效,正在录制时重启录制
|
// 档位只对新分段生效,正在录制时重启录制
|
||||||
if wasRecording {
|
if wasRecording {
|
||||||
stopRecord(uid)
|
stopRecord(uid)
|
||||||
startRecord(uid)
|
startRecord(uid)
|
||||||
lPrintf("已将 uid %d 的录播码率上限改为 %d Kbps 并重启录制", uid, bitrate)
|
lPrintf("已将 uid %d 的录制档位改为 %s并重启录制", uid, qualityLogText(pref, custom))
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 档位的中文描述,用于日志
|
||||||
|
func qualityLogText(pref string, custom int) string {
|
||||||
|
if normalizeQualityPref(pref) == qualityPrefCustom {
|
||||||
|
return fmt.Sprintf("自定义(不超过 %d Kbps)", custom)
|
||||||
|
}
|
||||||
|
return qualityPrefText(pref)
|
||||||
|
}
|
||||||
|
|
||||||
// 停止或重新开始指定主播的录制,target 为 danmu、record 或 all(空字符串表示全部),action 为 stop 或 restart
|
// 停止或重新开始指定主播的录制,target 为 danmu、record 或 all(空字符串表示全部),action 为 stop 或 restart
|
||||||
func controlStreamer(uid int, target, action string) error {
|
func controlStreamer(uid int, target, action string) error {
|
||||||
if uid <= 0 {
|
if uid <= 0 {
|
||||||
@@ -885,7 +1131,9 @@ func (s *streamer) moveDanmuFiles(assFile string) {
|
|||||||
s.moveFile(jsonlFile)
|
s.moveFile(jsonlFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移动文件
|
// 把送出文件到保存目录下,路径一致时什么也不做。
|
||||||
|
// 录制的产出本来就写在 recordSaveDir() 里(见 recordSaveDir 的注释),
|
||||||
|
// 这里是条保险:路径由别处给进来、或者运行中被改过保存目录时,仍以当前配置为准收拢一次。
|
||||||
func (s *streamer) moveFile(oldFile string) {
|
func (s *streamer) moveFile(oldFile string) {
|
||||||
if oldFile == "" {
|
if oldFile == "" {
|
||||||
return
|
return
|
||||||
|
|||||||
+193
@@ -0,0 +1,193 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 「合并失败」推送是新增的开关,老设置文件里没有这个字段 —— 升级后不能静默关掉,
|
||||||
|
// 也不能把用户显式写的 false 顶回 true。同一个坑「推送渠道」已经踩过一次
|
||||||
|
func TestLoadConfigPresetsNotifyMergeFail(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
savedConfig := config
|
||||||
|
savedLocation := configFileLocation
|
||||||
|
oldDir := configDir
|
||||||
|
if configDir == nil {
|
||||||
|
configDir = new(string)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
config = savedConfig
|
||||||
|
configFileLocation = savedLocation
|
||||||
|
configDir = oldDir
|
||||||
|
}()
|
||||||
|
*configDir = dir
|
||||||
|
configFileLocation = filepath.Join(dir, configFile)
|
||||||
|
|
||||||
|
// 老文件:连 notifyEnabled 都没有,两个开关都该是默认开
|
||||||
|
content, _ := json.Marshal(map[string]any{"uids": []any{}, "mergeFps": 60})
|
||||||
|
if err := os.WriteFile(configFileLocation, content, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
config.NotifyMergeFail = false // 先弄脏,确认是 Unmarshal 之前被顶回 true 的
|
||||||
|
loadConfig()
|
||||||
|
if !config.NotifyMergeFail {
|
||||||
|
t.Error("老设置文件里没有 notifyMergeFail 时应当按开启处理")
|
||||||
|
}
|
||||||
|
if config.MergeFps != 60 {
|
||||||
|
t.Errorf("mergeFps 没读进来:%d", config.MergeFps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件里显式写了 false,必须尊重
|
||||||
|
content, _ = json.Marshal(map[string]any{"notifyMergeFail": false, "mergeFps": 0})
|
||||||
|
if err := os.WriteFile(configFileLocation, content, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
config.NotifyMergeFail = true
|
||||||
|
loadConfig()
|
||||||
|
if config.NotifyMergeFail {
|
||||||
|
t.Error("文件里显式写的 notifyMergeFail=false 被顶回了 true")
|
||||||
|
}
|
||||||
|
if config.MergeFps != 0 {
|
||||||
|
t.Errorf("mergeFps 应当是 0,实际 %d", config.MergeFps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 命令行合成模式(-danmuburn)不走 initialize(),exeDir 必须由 loadConfigForDanmuburn 自己定:
|
||||||
|
// 漏掉的话头像缓存就变成「当前工作目录/avatar_cache」—— 换个目录跑要重新下一遍头像,
|
||||||
|
// 更糟的是旧版把头像一个相对路径写进滤镜,而 ffmpeg 的工作目录是弹幕目录,
|
||||||
|
// 于是找不到文件、整块渲染失败(报错只显示「打不开 avatar_cache\xxx.png」)
|
||||||
|
func TestLoadConfigForDanmuburnSetsExeDir(t *testing.T) {
|
||||||
|
savedConfig := config
|
||||||
|
savedExe, savedLocation := exeDir, configFileLocation
|
||||||
|
oldDir, oldRecordDir := configDir, recordDir
|
||||||
|
if configDir == nil {
|
||||||
|
configDir = new(string)
|
||||||
|
}
|
||||||
|
if recordDir == nil {
|
||||||
|
recordDir = new(string)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
config = savedConfig
|
||||||
|
exeDir, configFileLocation = savedExe, savedLocation
|
||||||
|
configDir, recordDir = oldDir, oldRecordDir
|
||||||
|
}()
|
||||||
|
|
||||||
|
exeDir = ""
|
||||||
|
*configDir = ""
|
||||||
|
loadConfigForDanmuburn()
|
||||||
|
|
||||||
|
if exeDir == "" {
|
||||||
|
t.Fatal("loadConfigForDanmuburn 应当把 exeDir 定下来,否则头像缓存会落到当前工作目录")
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(avatarCachePath()) {
|
||||||
|
t.Fatalf("头像缓存路径应当是绝对路径,实际 %q", avatarCachePath())
|
||||||
|
}
|
||||||
|
if filepath.Dir(configFileLocation) != exeDir {
|
||||||
|
t.Errorf("没指定 -config 时设置文件应当在程序目录,实际 %q(exeDir=%q)", configFileLocation, exeDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改设置会重新读取配置(web UI 保存、或直接改 config.json),
|
||||||
|
// 这一步绝不能删掉正在进行的合并的临时文件 —— 否则合并会当场失败。
|
||||||
|
func TestReloadConfigKeepsMergeTempFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
savedConfig := config
|
||||||
|
savedLocation := configFileLocation
|
||||||
|
oldDir := configDir
|
||||||
|
if configDir == nil {
|
||||||
|
configDir = new(string)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
config = savedConfig
|
||||||
|
configFileLocation = savedLocation
|
||||||
|
configDir = oldDir
|
||||||
|
}()
|
||||||
|
|
||||||
|
*configDir = dir
|
||||||
|
configFileLocation = filepath.Join(dir, configFile)
|
||||||
|
|
||||||
|
recDir := filepath.Join(dir, defaultRecordSubdir)
|
||||||
|
if err := os.MkdirAll(recDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 假装一个合并正在跑:录播目录里有分块字幕和先本地合成的成片
|
||||||
|
inUse := []string{
|
||||||
|
filepath.Join(recDir, chunkTempPrefix+"single.ass"),
|
||||||
|
filepath.Join(recDir, chunkTempPrefix+"merging_x.mp4"),
|
||||||
|
}
|
||||||
|
for _, p := range inUse {
|
||||||
|
if err := os.WriteFile(p, []byte("x"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content, _ := json.Marshal(map[string]string{"directory": filepath.ToSlash(dir)})
|
||||||
|
if err := os.WriteFile(configFileLocation, content, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 重新读取配置的真实路径:loadNewConfig() 就是 loadConfig() + checkConfig()
|
||||||
|
loadConfig()
|
||||||
|
checkConfig()
|
||||||
|
|
||||||
|
for _, p := range inUse {
|
||||||
|
if _, err := os.Stat(p); err != nil {
|
||||||
|
t.Errorf("重新读取配置把正在用的临时文件删了:%s", filepath.Base(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动时的兜底清理还得照旧能清掉上次残留
|
||||||
|
cleanLeftoverTempFiles()
|
||||||
|
for _, p := range inUse {
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
t.Errorf("启动时的清理没清掉残留:%s", filepath.Base(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存目录填错(盘没插、路径拼错)时启动必须给一句人话再退出,
|
||||||
|
// 不能 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,8 +16,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 不同的视频分辨率对应的弹幕字幕设置
|
// 不同的视频分辨率对应的弹幕字幕设置
|
||||||
|
// 字号一律取「短边 × 60/1080」(横屏取高、竖屏取宽),和合并端的自动字号一致
|
||||||
var subConfigs = map[int]acfundanmu.SubConfig{
|
var subConfigs = map[int]acfundanmu.SubConfig{
|
||||||
0: {PlayResX: 720, PlayResY: 1280, FontSize: 60}, // 这是手机直播,有一些是 540X960
|
0: {PlayResX: 720, PlayResY: 1280, FontSize: 40}, // 这是手机直播,有一些是 540X960
|
||||||
540: {PlayResX: 960, PlayResY: 540, FontSize: 30},
|
540: {PlayResX: 960, PlayResY: 540, FontSize: 30},
|
||||||
720: {PlayResX: 1280, PlayResY: 720, FontSize: 40},
|
720: {PlayResX: 1280, PlayResY: 720, FontSize: 40},
|
||||||
1080: {PlayResX: 1920, PlayResY: 1080, FontSize: 60},
|
1080: {PlayResX: 1920, PlayResY: 1080, FontSize: 60},
|
||||||
@@ -145,6 +146,18 @@ func textWidth(text string, fontSize int) int {
|
|||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 弹幕滚动的两个关键时刻:完全进入(emerge)和完全离开(left)。
|
||||||
|
// resX 是画面宽度、length 是弹幕宽度,两者相加是它要走的整段行程。
|
||||||
|
// 配置异常(PlayResX 为 0)碰上空内容时这段行程会是 0,直接拿它做除数是整数除零 panic
|
||||||
|
// (会终止整场录制),这种情况退回「半个停留时长」,至少让它正常显示出来。
|
||||||
|
func scrollMarks(rel int64, resX, length int, show int64) (emerge, left int64) {
|
||||||
|
total := int64(resX + length)
|
||||||
|
if total <= 0 {
|
||||||
|
return rel + show/2, rel + show
|
||||||
|
}
|
||||||
|
return rel + int64(length)*show/total, rel + int64(resX)*show/total
|
||||||
|
}
|
||||||
|
|
||||||
// 在弹道数组里寻找空闲弹道,返回弹道行号,没有空闲返回 -1,公式与 acfundanmu 保持一致
|
// 在弹道数组里寻找空闲弹道,返回弹道行号,没有空闲返回 -1,公式与 acfundanmu 保持一致
|
||||||
func placeLane(last []laneState, dt laneState, leftTime int64) int {
|
func placeLane(last []laneState, dt laneState, leftTime int64) int {
|
||||||
for i, t := range last {
|
for i, t := range last {
|
||||||
@@ -164,6 +177,21 @@ type danmuWriter struct {
|
|||||||
jsonl *os.File
|
jsonl *os.File
|
||||||
lastO []laneState // 原格式的弹道状态
|
lastO []laneState // 原格式的弹道状态
|
||||||
lastN []laneState // 昵称头像版的弹道状态
|
lastN []laneState // 昵称头像版的弹道状态
|
||||||
|
err error // 写盘出过的错。只记头一个,见 danmuWriter.fail
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记一次写盘错误。只保留第一个:磁盘满了之后每写一条都会失败,
|
||||||
|
// 全记下来只会把日志刷爆,而第一个就足以说明原因
|
||||||
|
func (w *danmuWriter) fail(what string, err error) {
|
||||||
|
if w.err == nil {
|
||||||
|
w.err = fmt.Errorf("%s失败:%v", what, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本场弹幕有没有写坏。写坏的文件不能当正常的用 —— 否则合并出来的成片
|
||||||
|
// 会缺一大截弹幕,而日志里什么都看不出来
|
||||||
|
func (w *danmuWriter) failed() bool {
|
||||||
|
return w.err != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回昵称头像版 ASS 和 jsonl 的文件路径
|
// 返回昵称头像版 ASS 和 jsonl 的文件路径
|
||||||
@@ -188,17 +216,33 @@ func newDanmuWriter(liveID, streamName string, cfg acfundanmu.SubConfig, assFile
|
|||||||
w.jsonl, err = os.OpenFile(jsonlFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
w.jsonl, err = os.OpenFile(jsonlFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
||||||
checkErr(err)
|
checkErr(err)
|
||||||
|
|
||||||
fmt.Fprintf(w.ass, scriptInfoTmpl, liveID, streamName, cfg.Title, cfg.PlayResX, cfg.PlayResY)
|
if _, err := fmt.Fprintf(w.ass, scriptInfoTmpl, liveID, streamName, cfg.Title, cfg.PlayResX, cfg.PlayResY); err != nil {
|
||||||
fmt.Fprintf(w.ass, stylesTmpl, cfg.FontSize)
|
w.fail("写原格式 ASS 文件头", err)
|
||||||
fmt.Fprint(w.ass, eventsTmpl)
|
}
|
||||||
fmt.Fprintf(w.nick, scriptInfoTmpl, liveID, streamName, cfg.Title+" 昵称头像版", cfg.PlayResX, cfg.PlayResY)
|
if _, err := fmt.Fprintf(w.ass, stylesTmpl, cfg.FontSize); err != nil {
|
||||||
fmt.Fprintf(w.nick, stylesTmpl, cfg.FontSize)
|
w.fail("写原格式 ASS 文件头", err)
|
||||||
fmt.Fprint(w.nick, eventsTmpl)
|
}
|
||||||
|
if _, err := fmt.Fprint(w.ass, eventsTmpl); err != nil {
|
||||||
|
w.fail("写原格式 ASS 文件头", err)
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(w.nick, scriptInfoTmpl, liveID, streamName, cfg.Title+" 昵称头像版", cfg.PlayResX, cfg.PlayResY); err != nil {
|
||||||
|
w.fail("写昵称头像版 ASS 文件头", err)
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(w.nick, stylesTmpl, cfg.FontSize); err != nil {
|
||||||
|
w.fail("写昵称头像版 ASS 文件头", err)
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprint(w.nick, eventsTmpl); err != nil {
|
||||||
|
w.fail("写昵称头像版 ASS 文件头", err)
|
||||||
|
}
|
||||||
|
|
||||||
meta, err := json.Marshal(danmuMeta{PlayResX: cfg.PlayResX, PlayResY: cfg.PlayResY, FontSize: cfg.FontSize, StartTime: cfg.StartTime})
|
meta, err := json.Marshal(danmuMeta{PlayResX: cfg.PlayResX, PlayResY: cfg.PlayResY, FontSize: cfg.FontSize, StartTime: cfg.StartTime})
|
||||||
checkErr(err)
|
checkErr(err)
|
||||||
w.jsonl.Write(meta)
|
if _, err := w.jsonl.Write(meta); err != nil {
|
||||||
w.jsonl.Write([]byte("\n"))
|
w.fail("写弹幕 jsonl 文件头", err)
|
||||||
|
}
|
||||||
|
if _, err := w.jsonl.Write([]byte("\n")); err != nil {
|
||||||
|
w.fail("写弹幕 jsonl 文件头", err)
|
||||||
|
}
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,17 +254,19 @@ func (w *danmuWriter) write(c *acfundanmu.Comment) {
|
|||||||
|
|
||||||
// 原格式 ASS,碰撞避让和坐标公式与 acfundanmu 完全一致
|
// 原格式 ASS,碰撞避让和坐标公式与 acfundanmu 完全一致
|
||||||
length := utf8.RuneCountInString(c.Content) * s.FontSize
|
length := utf8.RuneCountInString(c.Content) * s.FontSize
|
||||||
|
emergeO, leftO := scrollMarks(rel, s.PlayResX, length, danmuShowDur)
|
||||||
dtO := laneState{
|
dtO := laneState{
|
||||||
appear: rel,
|
appear: rel,
|
||||||
emerge: rel + (int64(length)*danmuShowDur)/int64(s.PlayResX+length),
|
emerge: emergeO,
|
||||||
disappear: rel + danmuShowDur,
|
disappear: rel + danmuShowDur,
|
||||||
}
|
}
|
||||||
leftO := rel + (int64(s.PlayResX)*danmuShowDur)/int64(s.PlayResX+length)
|
|
||||||
if i := placeLane(w.lastO, dtO, leftO); i >= 0 {
|
if i := placeLane(w.lastO, dtO, leftO); i >= 0 {
|
||||||
fmt.Fprintf(w.ass, dialogueTmpl,
|
if _, err := fmt.Fprintf(w.ass, dialogueTmpl,
|
||||||
assTime(dtO.appear), assTime(dtO.disappear),
|
assTime(dtO.appear), assTime(dtO.disappear),
|
||||||
strings.ReplaceAll(c.Nickname, ",", " "), c.UserID,
|
strings.ReplaceAll(c.Nickname, ",", " "), c.UserID,
|
||||||
s.PlayResX+length/2, s.FontSize*(i+1), -length/2, s.FontSize*(i+1), c.Content)
|
s.PlayResX+length/2, s.FontSize*(i+1), -length/2, s.FontSize*(i+1), c.Content); err != nil {
|
||||||
|
w.fail("写原格式 ASS", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 昵称头像版 ASS,\an7 锚点在文本左上角,头像由 ffmpeg 叠在文字左侧,不依赖字体渲染宽度
|
// 昵称头像版 ASS,\an7 锚点在文本左上角,头像由 ffmpeg 叠在文字左侧,不依赖字体渲染宽度
|
||||||
@@ -228,17 +274,19 @@ func (w *danmuWriter) write(c *acfundanmu.Comment) {
|
|||||||
width := textWidth(text, s.FontSize)
|
width := textWidth(text, s.FontSize)
|
||||||
avW, avGap := avatarSize(s.FontSize), avatarGap(s.FontSize)
|
avW, avGap := avatarSize(s.FontSize), avatarGap(s.FontSize)
|
||||||
total := avW + avGap + width // 头像、间隔和文字的总宽度
|
total := avW + avGap + width // 头像、间隔和文字的总宽度
|
||||||
|
emergeN, leftN := scrollMarks(rel, s.PlayResX, total, danmuShowDur)
|
||||||
dtN := laneState{
|
dtN := laneState{
|
||||||
appear: rel,
|
appear: rel,
|
||||||
emerge: rel + (int64(total)*danmuShowDur)/int64(s.PlayResX+total),
|
emerge: emergeN,
|
||||||
disappear: rel + danmuShowDur,
|
disappear: rel + danmuShowDur,
|
||||||
}
|
}
|
||||||
leftN := rel + (int64(s.PlayResX)*danmuShowDur)/int64(s.PlayResX+total)
|
|
||||||
if i := placeLane(w.lastN, dtN, leftN); i >= 0 {
|
if i := placeLane(w.lastN, dtN, leftN); i >= 0 {
|
||||||
fmt.Fprintf(w.nick, dialogueNickTmpl,
|
if _, err := fmt.Fprintf(w.nick, dialogueNickTmpl,
|
||||||
assTime(dtN.appear), assTime(dtN.disappear),
|
assTime(dtN.appear), assTime(dtN.disappear),
|
||||||
strings.ReplaceAll(c.Nickname, ",", " "), c.UserID,
|
strings.ReplaceAll(c.Nickname, ",", " "), c.UserID,
|
||||||
s.PlayResX+avW+avGap, s.FontSize*i, -width, s.FontSize*i, text)
|
s.PlayResX+avW+avGap, s.FontSize*i, -width, s.FontSize*i, text); err != nil {
|
||||||
|
w.fail("写昵称头像版 ASS", err)
|
||||||
|
}
|
||||||
rec := danmuRecord{
|
rec := danmuRecord{
|
||||||
Time: float64(rel) / 1e9,
|
Time: float64(rel) / 1e9,
|
||||||
UID: c.UserID,
|
UID: c.UserID,
|
||||||
@@ -250,8 +298,12 @@ func (w *danmuWriter) write(c *acfundanmu.Comment) {
|
|||||||
}
|
}
|
||||||
b, err := json.Marshal(rec)
|
b, err := json.Marshal(rec)
|
||||||
checkErr(err)
|
checkErr(err)
|
||||||
w.jsonl.Write(b)
|
if _, err := w.jsonl.Write(b); err != nil {
|
||||||
w.jsonl.Write([]byte("\n"))
|
w.fail("写弹幕 jsonl", err)
|
||||||
|
}
|
||||||
|
if _, err := w.jsonl.Write([]byte("\n")); err != nil {
|
||||||
|
w.fail("写弹幕 jsonl", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,11 +327,20 @@ func (w *danmuWriter) consume(ctx context.Context, ac *acfundanmu.AcFunLive) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭所有文件
|
// 关闭所有文件,并把整个录制过程中出过的写盘错误一并交出来。
|
||||||
func (w *danmuWriter) close() {
|
// Close 自己的错误也要记:os.File 在部分系统上(NFS、某些网络盘)是到 Close
|
||||||
w.ass.Close()
|
// 才报出「写不进去」的,只盯每次 Write 的返回值会漏掉这种情况
|
||||||
w.nick.Close()
|
func (w *danmuWriter) close() error {
|
||||||
w.jsonl.Close()
|
if err := w.ass.Close(); err != nil {
|
||||||
|
w.fail("关闭原格式 ASS", err)
|
||||||
|
}
|
||||||
|
if err := w.nick.Close(); err != nil {
|
||||||
|
w.fail("关闭昵称头像版 ASS", err)
|
||||||
|
}
|
||||||
|
if err := w.jsonl.Close(); err != nil {
|
||||||
|
w.fail("关闭弹幕 jsonl", err)
|
||||||
|
}
|
||||||
|
return w.err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 录制流程 ====================
|
// ==================== 录制流程 ====================
|
||||||
@@ -304,7 +365,15 @@ func (s streamer) getDanmu(ctx context.Context, info liveInfo) {
|
|||||||
_ = ac.StartDanmu(ctx, false)
|
_ = ac.StartDanmu(ctx, false)
|
||||||
streamInfo := ac.GetStreamInfo()
|
streamInfo := ac.GetStreamInfo()
|
||||||
w := newDanmuWriter(streamInfo.LiveID, streamInfo.StreamName, info.cfg, info.assFile)
|
w := newDanmuWriter(streamInfo.LiveID, streamInfo.StreamName, info.cfg, info.assFile)
|
||||||
defer w.close()
|
// 写盘出错要到关文件时才收得全(有的系统到 Close 才报「写不进去」)。
|
||||||
|
// 必须说出来:否则本场照常自动合并,出来的成片缺一大截弹幕,日志里却一切正常
|
||||||
|
defer func() {
|
||||||
|
if err := w.close(); err != nil {
|
||||||
|
lPrintErrf("录制%s的直播弹幕时写盘出错,弹幕文件不完整:%v", s.longID(), err)
|
||||||
|
lPrintErrf("本场不做自动合并,弹幕文件保留在 %s,请检查磁盘空间后手动合并", info.assFile)
|
||||||
|
updateLiveInfo(info.LiveID, func(i *liveInfo) { i.danmuWriteFailed = true })
|
||||||
|
}
|
||||||
|
}()
|
||||||
w.consume(ctx, ac)
|
w.consume(ctx, ac)
|
||||||
|
|
||||||
// 断流后先观察一个窗口:网络波动或主播短暂掉线(20 秒内)恢复时沿用同一个写入器接着写,
|
// 断流后先观察一个窗口:网络波动或主播短暂掉线(20 秒内)恢复时沿用同一个写入器接着写,
|
||||||
|
|||||||
+1328
-175
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,309 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== ffmpeg 报错的可见性 ====================
|
||||||
|
|
||||||
|
// 尾部 writer 只留最近若干行(ffmpeg 的进度行会不停刷)
|
||||||
|
func TestTailWriterKeepsLastLines(t *testing.T) {
|
||||||
|
w := newTailWriter(3)
|
||||||
|
fmt.Fprint(w, "第1行\n第2行\n第3行\r第4行\n第5行\n")
|
||||||
|
got := w.String()
|
||||||
|
if !strings.Contains(got, "第5行") {
|
||||||
|
t.Fatalf("应当保留最后一行:%q", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "第1行") || strings.Contains(got, "第2行") {
|
||||||
|
t.Fatalf("超出保留行数的旧内容应当被丢掉:%q", got)
|
||||||
|
}
|
||||||
|
if lines := strings.Split(got, "\n"); len(lines) != 3 {
|
||||||
|
t.Fatalf("应当只留 3 行,实际 %d 行:%q", len(lines), got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 失败时要把 ffmpeg 自己的报错带上,不然只剩一句 exit status
|
||||||
|
func TestFFmpegResultCarriesStderr(t *testing.T) {
|
||||||
|
err := ffmpegResult(errors.New("exit status 0xffffffea"), "Error binding filtergraph inputs/outputs")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("应当返回错误")
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
if !strings.Contains(msg, "exit status 0xffffffea") || !strings.Contains(msg, "Error binding filtergraph") {
|
||||||
|
t.Fatalf("错误里应当同时有退出状态和 ffmpeg 的说明,实际:%s", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成功时不带任何东西
|
||||||
|
if got := ffmpegResult(nil, "随便什么输出"); got != nil {
|
||||||
|
t.Fatalf("成功时应当返回 nil,实际 %v", got)
|
||||||
|
}
|
||||||
|
// 没有 stderr 时保持原样
|
||||||
|
plain := errors.New("exit status 1")
|
||||||
|
if got := ffmpegResult(plain, " "); got != plain {
|
||||||
|
t.Fatalf("没有 ffmpeg 输出时应当原样返回,实际 %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跟编码器无关的失败不该挨个回退重试
|
||||||
|
func TestEncoderMightHelp(t *testing.T) {
|
||||||
|
unrelated := []string{
|
||||||
|
"Error binding filtergraph inputs/outputs: Invalid argument",
|
||||||
|
"Error initializing filtergraph",
|
||||||
|
"Error opening input: No such file or directory",
|
||||||
|
}
|
||||||
|
for _, msg := range unrelated {
|
||||||
|
if encoderMightHelp(errors.New(msg)) {
|
||||||
|
t.Fatalf("这类失败跟编码器无关,不该回退重试:%s", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
related := []string{
|
||||||
|
"Error initializing output stream 0:0 -- Error while opening encoder",
|
||||||
|
"hevc_qsv: Function not implemented",
|
||||||
|
}
|
||||||
|
for _, msg := range related {
|
||||||
|
if !encoderMightHelp(errors.New(msg)) {
|
||||||
|
t.Fatalf("编码器自身的失败应当允许回退:%s", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 头像缓存完整性 ====================
|
||||||
|
|
||||||
|
func tinyPNG(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 8, 8))); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 半截的 PNG 不能被当成可用缓存
|
||||||
|
func TestValidCachedAvatar(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
good := tinyPNG(t)
|
||||||
|
|
||||||
|
full := filepath.Join(dir, "full.png")
|
||||||
|
if err := os.WriteFile(full, good, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !validCachedAvatar(full) {
|
||||||
|
t.Fatal("完整的 PNG 应当算有效")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 砍掉尾巴(模拟下载中断)
|
||||||
|
cut := filepath.Join(dir, "cut.png")
|
||||||
|
if err := os.WriteFile(cut, good[:len(good)-8], 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if validCachedAvatar(cut) {
|
||||||
|
t.Fatal("半截 PNG 不该算有效")
|
||||||
|
}
|
||||||
|
|
||||||
|
empty := filepath.Join(dir, "empty.png")
|
||||||
|
if err := os.WriteFile(empty, nil, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if validCachedAvatar(empty) {
|
||||||
|
t.Fatal("空文件不该算有效")
|
||||||
|
}
|
||||||
|
if validCachedAvatar(filepath.Join(dir, "不存在.png")) {
|
||||||
|
t.Fatal("不存在的文件不该算有效")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 命中的是半截缓存时要重新下载,下载完必须是完整 PNG
|
||||||
|
func TestEnsureAvatarRedownloadsBrokenCache(t *testing.T) {
|
||||||
|
withJobFlags(t, true, false, false)
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
var hits int64
|
||||||
|
body := tinyPNG(t)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
atomic.AddInt64(&hits, 1)
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
url := srv.URL + "/a.png"
|
||||||
|
path := filepath.Join(dir, avatarCacheName(42, url))
|
||||||
|
// 先摆一个「写了一半」的缓存
|
||||||
|
if err := os.WriteFile(path, body[:len(body)-8], 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if validCachedAvatar(path) {
|
||||||
|
t.Fatal("前置条件不成立:半截文件应当被判为无效")
|
||||||
|
}
|
||||||
|
|
||||||
|
got := ensureAvatar(42, url, dir)
|
||||||
|
if got != path {
|
||||||
|
t.Fatalf("应当返回缓存路径 %q,实际 %q", path, got)
|
||||||
|
}
|
||||||
|
if atomic.LoadInt64(&hits) == 0 {
|
||||||
|
t.Fatal("半截缓存应当触发重新下载")
|
||||||
|
}
|
||||||
|
if !validCachedAvatar(path) {
|
||||||
|
t.Fatal("重新下载之后应当是完整的 PNG")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完好的缓存不该再走网络
|
||||||
|
func TestEnsureAvatarUsesGoodCache(t *testing.T) {
|
||||||
|
withJobFlags(t, true, false, false)
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
var hits int64
|
||||||
|
body := tinyPNG(t)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
atomic.AddInt64(&hits, 1)
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
url := srv.URL + "/b.png"
|
||||||
|
path := filepath.Join(dir, avatarCacheName(43, url))
|
||||||
|
if err := os.WriteFile(path, body, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := ensureAvatar(43, url, dir); got != path {
|
||||||
|
t.Fatalf("应当直接命中缓存,实际 %q", got)
|
||||||
|
}
|
||||||
|
if n := atomic.LoadInt64(&hits); n != 0 {
|
||||||
|
t.Fatalf("完好的缓存不该再请求网络,实际 %d 次", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载过程中不会留下能被误用的半截文件
|
||||||
|
func TestDownloadAvatarWritesAtomically(t *testing.T) {
|
||||||
|
withJobFlags(t, true, false, false)
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write(tinyPNG(t))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
url := srv.URL + "/c.png"
|
||||||
|
path := filepath.Join(dir, avatarCacheName(44, url))
|
||||||
|
if err := downloadAvatar(44, url, path); err != nil {
|
||||||
|
t.Fatalf("下载不该失败:%v", err)
|
||||||
|
}
|
||||||
|
if !validCachedAvatar(path) {
|
||||||
|
t.Fatal("下载完的缓存应当是完整 PNG")
|
||||||
|
}
|
||||||
|
// 临时文件不该留下
|
||||||
|
if entries, err := os.ReadDir(dir); err == nil {
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.HasSuffix(e.Name(), ".part") {
|
||||||
|
t.Fatalf("不该留下临时文件:%s", e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录当前日志长度,返回一个能读到之后新增内容的函数
|
||||||
|
func logCursor() func() string {
|
||||||
|
logString.Lock()
|
||||||
|
start := logString.str.Len()
|
||||||
|
logString.Unlock()
|
||||||
|
return func() string {
|
||||||
|
logString.Lock()
|
||||||
|
defer logString.Unlock()
|
||||||
|
data := logString.str.String()
|
||||||
|
if start > len(data) {
|
||||||
|
return data // 中间被 Reset 过,那就给全部
|
||||||
|
}
|
||||||
|
return data[start:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 头像输入打不开属于「跟编码器无关」的失败:不该挨个编码器回退重试,
|
||||||
|
// 而且 ffmpeg 自己的报错必须留在日志里(否则只剩一句 exit status 0xffffffea)
|
||||||
|
func TestRenderChunkDoesNotFallBackOnUnrelatedFailure(t *testing.T) {
|
||||||
|
ffmpegFile := getFFmpeg()
|
||||||
|
if ffmpegFile == "" {
|
||||||
|
t.Skip("找不到 ffmpeg,跳过")
|
||||||
|
}
|
||||||
|
withJobFlags(t, true, false, false)
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
video := filepath.Join(dir, "in.mp4")
|
||||||
|
makeTinyVideo(t, ffmpegFile, video, "red")
|
||||||
|
|
||||||
|
uid := int64(1)
|
||||||
|
chunk := danmuChunk{start: 0, end: 1, records: []danmuRecord{
|
||||||
|
{Time: 0.2, UID: uid, Name: "甲", Content: "正常一条", Lane: 0, Width: 100},
|
||||||
|
}}
|
||||||
|
ctx := &burnContext{
|
||||||
|
ffmpegFile: ffmpegFile,
|
||||||
|
videoFile: video,
|
||||||
|
outFile: filepath.Join(dir, "out.mp4"),
|
||||||
|
meta: danmuMeta{PlayResX: 160, PlayResY: 120, FontSize: 20},
|
||||||
|
// 头像文件根本不存在:ffmpeg 打开输入就会失败
|
||||||
|
avatars: map[int64]string{uid: filepath.Join(dir, "根本没有这个头像.png")},
|
||||||
|
dir: dir,
|
||||||
|
// 故意不指定 encoder:从候选列表第一个开始,才能看出有没有挨个回退
|
||||||
|
}
|
||||||
|
ad, gap := avatarSize(ctx.meta.FontSize), avatarGap(ctx.meta.FontSize)
|
||||||
|
if err := writeChunkAss(filepath.Join(dir, "chunk.ass"), ctx.meta, chunk, 0, ad, gap); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
read := logCursor()
|
||||||
|
err := ctx.renderChunk(chunk, "chunk.ass", filepath.Join(dir, "out.mp4"), false, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("头像打不开时应当渲染失败")
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
if !strings.Contains(msg, "ffmpeg 最后的输出") {
|
||||||
|
t.Fatalf("错误里应当带上 ffmpeg 自己的输出:%s", msg)
|
||||||
|
}
|
||||||
|
if log := read(); strings.Contains(log, "回退到") {
|
||||||
|
t.Fatalf("输入打不开跟编码器无关,不该挨个回退重试:\n%s", log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动时那段版本/编译配置要过滤掉,别把真正的报错挤出日志
|
||||||
|
func TestTailWriterSkipsStartupNoise(t *testing.T) {
|
||||||
|
w := newTailWriter(10)
|
||||||
|
fmt.Fprint(w, "ffmpeg version n9.0.2-3\n"+
|
||||||
|
" built with gcc 16.2.0\n"+
|
||||||
|
" configuration: --prefix=/ffbuild --enable-gpl --enable-libass 等等很长的一行\n"+
|
||||||
|
" libavutil 61. 1.102 / 61. 1.102\n"+
|
||||||
|
" libswscale 10. 1.102 / 10. 1.102\n"+
|
||||||
|
"[in#0 @ 0x1] moov atom not found\n"+
|
||||||
|
"Error opening input files: Invalid data found when processing input\n")
|
||||||
|
got := w.String()
|
||||||
|
for _, noise := range []string{"ffmpeg version", "configuration:", "libavutil", "libswscale", "built with"} {
|
||||||
|
if strings.Contains(got, noise) {
|
||||||
|
t.Fatalf("启动信息应当被过滤掉(%s),实际:%q", noise, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "moov atom not found") || !strings.Contains(got, "Invalid data found") {
|
||||||
|
t.Fatalf("真正的报错必须保留:%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 头像直径再小也要 >= 1,否则 scale=0:0 会让 ffmpeg 直接 EINVAL
|
||||||
|
func TestAvatarSizeNeverZero(t *testing.T) {
|
||||||
|
for _, fs := range []int{0, 1, 2, 5, 42, 60} {
|
||||||
|
if got := avatarSize(fs); got < 1 {
|
||||||
|
t.Fatalf("字号 %d 算出的头像直径是 %d,应当至少为 1", fs, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := avatarSize(60); got != 48 {
|
||||||
|
t.Fatalf("正常字号的口径不该变:60 -> %d(应为 48)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
`acfunlive -mirai` 利用 Mirai 发送直播通知到指定 QQ 或 QQ 群
|
`acfunlive -mirai` 利用 Mirai 发送直播通知到指定 QQ 或 QQ 群
|
||||||
|
|
||||||
`acfunlive -danmuburn xxx.jsonl -video xxx.flv` 把弹幕合成到视频里,不指定`-video`时只输出带头像的弹幕层。弹幕文件也接受上游原版的`.ass`(会先还原成 jsonl 再合并),可以用`-out`、`-offset`、`-vbitrate`、`-ffmpeg`分别指定输出文件、时间偏移、目标码率和 ffmpeg 的位置
|
`acfunlive -danmuburn xxx.jsonl -video xxx.flv` 把弹幕合成到视频里,不指定`-video`时只输出带头像的弹幕层。弹幕文件也接受上游原版的`.ass`(会先还原成 jsonl 再合并),可以用`-out`、`-offset`、`-vbitrate`、`-ffmpeg`分别指定输出文件、时间偏移、目标码率和 ffmpeg 的位置。`-vbitrate`不填(或 0)且配置文件里的`mergeBitrate`也是 0 时,按「源片码率 + 500 Kbps」自动算;编码器优先硬件 HEVC(hevc_qsv),依次回退 h264_qsv、libx264
|
||||||
|
|
||||||
`acfunlive -listlive` 列出正在直播的主播
|
`acfunlive -listlive` 列出正在直播的主播
|
||||||
|
|
||||||
|
|||||||
+29
-3
@@ -80,7 +80,13 @@ web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全
|
|||||||
|
|
||||||
口令支持任意字符,包括中文——不过 HTTP 请求头只接受 ISO-8859-1,浏览器遇到非 ASCII 的口令会直接拒绝发请求(报`String contains non ISO-8859-1 code point`),所以**页面发给服务端的`X-Auth-Token`是百分号编码过的口令**(`encodeURIComponent`)。服务端两种写法都认:先拿原样比,再拿解码后的比,所以手写 curl 或者脚本按原样把中文口令放进请求头、`Authorization: Bearer`、`?token=` 里也都能通过。用脚本调这套接口时不用管编码,原样填就行。
|
口令支持任意字符,包括中文——不过 HTTP 请求头只接受 ISO-8859-1,浏览器遇到非 ASCII 的口令会直接拒绝发请求(报`String contains non ISO-8859-1 code point`),所以**页面发给服务端的`X-Auth-Token`是百分号编码过的口令**(`encodeURIComponent`)。服务端两种写法都认:先拿原样比,再拿解码后的比,所以手写 curl 或者脚本按原样把中文口令放进请求头、`Authorization: Bearer`、`?token=` 里也都能通过。用脚本调这套接口时不用管编码,原样填就行。
|
||||||
|
|
||||||
跟手动合并有关的是下面两个:
|
`GET /api/streamer/qualities?uid=<uid>` 去直播间接一次该主播的**码率档位**(返回`{"uid":…,"qualities":[{"name":"蓝光 4M","bitrate":4000},…],"qualityPref":"second","bitrate":0,"qualityBitrate":实际会用到的档位码率,"qualityName":实际会用到的档位名}`,`qualities`按码率从高到低)。读到的档位会缓存进`config.json`(没开播时页面靠它把偏好标成具体档位名),**但不会改动用户选的偏好**。同一个主播 10 秒内只让读一次,太频繁返回 502 并给中文原因(没开播时也会返回 502,且没开播时服务端本来就要重试三次,页面上会先给一句`正在读取码率档位…`)。
|
||||||
|
|
||||||
|
`POST /api/streamer`里的`qualityPref`是**按第几档**来选,而不是写死码率:`top`(最高档)、`second`(第二高,默认)、`lowest`(最低档)、`custom`(取不超过`bitrate`的最高档,都比它高时退回最低档)。四种偏好任何时候都能设,开播后自动对应到实际档位。`bitrate`只在`custom`时有意义:已存在的主播不传就保持原值(页面从`最高档`切到别的档位时不会把填过的自定义码率清掉)。`GET /api/status`里每个主播的`qualities`是缓存的档位表,`qualityPref`/`bitrate`是用户选的,`qualityBitrate`/`qualityName`是按偏好解析出来的结果(还没读到档位表时,前三种偏好解析为 0、`custom`解析为填的上限)。
|
||||||
|
|
||||||
|
跟手动合并有关的是下面三个:
|
||||||
|
`GET /api/merge/danmu?video=<视频文件名或路径>` 按视频名找同目录下的同名弹幕文件,给页面「选好视频自动匹配弹幕」用。依次试`.jsonl`、`.ass`、`.nick.ass`,同目录没有就拿文件名去录播目录再找一次;返回`{"danmu":"…"}`,找不到时是空字符串。值里录播目录/成片目录下的文件只写文件名,别处的写绝对路径,和`浏览`弹窗的填法一致。
|
||||||
|
|
||||||
`POST /api/merge/info` 预览一次手动合并的计划,不落盘。
|
`POST /api/merge/info` 预览一次手动合并的计划,不落盘。
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -93,11 +99,31 @@ web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全
|
|||||||
|
|
||||||
返回分段列表(名字、体积、时长、是否正在写入)、总时长、弹幕条数、弹幕时间范围、要用的弹幕文件和来源(`jsonl 弹幕数据` / `同名的 jsonl 弹幕数据` / `由 xxx.ass 还原`)、推算出的偏移和原因、最终输出文件名、以及提示(`warnings`)。参数不合法一律 400,并在响应体里给中文原因。
|
返回分段列表(名字、体积、时长、是否正在写入)、总时长、弹幕条数、弹幕时间范围、要用的弹幕文件和来源(`jsonl 弹幕数据` / `同名的 jsonl 弹幕数据` / `由 xxx.ass 还原`)、推算出的偏移和原因、最终输出文件名、以及提示(`warnings`)。参数不合法一律 400,并在响应体里给中文原因。
|
||||||
|
|
||||||
`POST /api/merge` 真正开始合并,请求体和上面一样,另外可以带`outName`指定输出文件名(不合法会被清理成安全的文件名,重名自动加后缀)。成功后立刻返回`{"outName":"…"}`,合并在后台跑。已经有一个手动合并任务在跑时返回 409。
|
`POST /api/merge` 真正开始合并,请求体和上面一样,另外可以带`outName`指定输出文件名(不合法会被清理成安全的文件名,重名自动加后缀)。成功后立刻返回`{"outName":"…"}`,合并在后台跑。已经有一个手动合并任务在跑时返回 409。**合并完成也会触发一次推送**(和自动合并同一个`mergeDone`事件):手动合并没有 uid,会拿成片或视频的文件名去对已有的主播名,只有一个能对上时才在文案里带上主播名,对不上就用`手动合并完成`。
|
||||||
|
|
||||||
合并状态通过`GET /api/status`查看:`mergeBusy`(是否在跑)、`mergeOutName`(当前成片名)、`mergeErr`(失败原因)、`mergeDoneAt`(上次完成时间)。合并期间成片会出现在`GET /api/files`里且`inUse`为 true,此时`POST /api/file/remove`会返回 409 拒绝删除。
|
合并状态通过`GET /api/status`查看:`mergeBusy`(是否在跑)、`mergeOutName`(当前成片名)、`mergeErr`(失败原因)、`mergeDoneAt`(上次完成时间)。合并期间成片会出现在`GET /api/files`里且`inUse`为 true,此时`POST /api/file/remove`会返回 409 拒绝删除。
|
||||||
|
|
||||||
`POST /api/config`里的`danmuFontSize`控制合并时弹幕文字的字号(像素),头像按同一比例缩放,填 0 表示沿用弹幕文件里记的字号。渲染前会按最终字号重排弹道:从上游原版`.ass`还原出来的弹幕只按内容宽度占位,而渲染用的是「昵称(uid):内容 + 头像」的完整宽度,占位时间更长,照搬原弹道会追尾叠字;重排按完整宽度重新分配,同一条弹道上不会再有两条弹幕叠在一起。
|
`GET /api/job` 返回转换任务(自动合并和手动合并共用一份)的实时进度,页面每秒轮一次它来画监控分区顶部的进度卡片和全局细进度条;空闲时返回`active: false`,但会保留上一次任务的结束信息(`doneAt`、`message`、`err`)方便界面把结果多显示一会儿。字段:
|
||||||
|
|
||||||
|
| 字段 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `kind` / `outName` | 任务类型(`手动合并` / `自动合并`)和成片文件名 |
|
||||||
|
| `phases` / `phaseIdx` / `phasePercent` | 阶段名列表、当前阶段序号、当前阶段内的完成比例 |
|
||||||
|
| `percent` | 总进度 0~100,按各阶段权重折算(渲染占 0.75) |
|
||||||
|
| `paused` / `stopping` / `pausable` | 是否已暂停 / 是否正在停止 / 现在能不能暂停 |
|
||||||
|
| `speed` / `fps` / `doneSec` / `totalSec` | 渲染倍速、帧率、已渲染时长、视频总时长 |
|
||||||
|
| `chunkDone` / `chunkTotal` | 分块渲染的进度(`第 3/12 块`) |
|
||||||
|
| `avatarDone` / `avatarTotal` | 获取头像阶段的用户数进度 |
|
||||||
|
| `inputBytes` / `estOutBytes` / `estSource` / `outBytes` | 源片总体积、预计成片体积及其来源(`bitrate` 按码率估 / `measured` 按已渲染部分外推)、已写出字节 |
|
||||||
|
| `etaSec` | 预计剩余秒数,算不出来时为 0 |
|
||||||
|
| `samples` | 渲染速度折线的采样点(约每秒一个,最多 120 个) |
|
||||||
|
| `message` / `err` / `doneAt` | 结束提示(`已完成` / `已停止` / `失败`)、失败原因、结束时刻 |
|
||||||
|
|
||||||
|
`POST /api/job/control` 控制当前任务,请求体`{"action":"pause"}`、`{"action":"resume"}`或`{"action":"stop"}`,成功返回`{"ok":true}`。没有任务在跑时返回 409,未知的 action 返回 400。暂停会挂起 ffmpeg 进程(Windows 用`NtSuspendProcess`、Linux 用`SIGSTOP`),CPU 占用归零;不在 ffmpeg 环节时暂停和停止会在下一个检查点生效。**停止会丢弃已经渲染的部分**:不落成片、清掉分块临时文件,任务以`message: "已停止"`结束(`err`为`任务已被手动停止`,界面据此和真失败区分开)。
|
||||||
|
|
||||||
|
`POST /api/config`里的`danmuFontSize`控制合并时弹幕文字的字号(像素),头像按同一比例缩放,填 0 表示按画面分辨率自动:字号取画面**短边**的 5.56%(横屏取高、竖屏取宽),所以 1920x1080 是 60、1280x720 是 40、720x1280 也是 40 —— 竖屏画面窄,若按高度算会得到 71,一条弹幕就铺满整行。显式填写则按填的像素值直接用。渲染前会按最终字号重排弹道:从上游原版`.ass`还原出来的弹幕只按内容宽度占位,而渲染用的是「昵称(uid):内容 + 头像」的完整宽度,占位时间更长,照搬原弹道会追尾叠字;重排按完整宽度重新分配,同一条弹道上不会再有两条弹幕叠在一起。
|
||||||
|
|
||||||
|
`mergeBitrate`填 0(默认)时按「源片码率 + 500 Kbps」自动计算:先探测视频流的码率,加 500 留给烧进去的弹幕;探测不到就退回质量模式。填大于 0 的值则按该码率固定输出。编码器优先硬件 HEVC(`hevc_qsv`,同画质比 H.264 省约三成体积,mp4 里打`hvc1`标记),不支持时回退`h264_qsv`、`libx264`,日志里会打印实际用的那一个。
|
||||||
|
|
||||||
`POST /api/notify/test` 测试某个推送渠道能不能连通。用传进来的值直接测,留空的项自动用已保存的配置补齐,所以不用先保存设置。测试不受渠道开关和事件勾选影响。
|
`POST /api/notify/test` 测试某个推送渠道能不能连通。用传进来的值直接测,留空的项自动用已保存的配置补齐,所以不用先保存设置。测试不受渠道开关和事件勾选影响。
|
||||||
|
|
||||||
|
|||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 编码器候选顺序:硬件 HEVC 最省 → 硬件 H.264 → CPU 软编
|
||||||
|
func TestEncoderCandidatesOrder(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
hevc, h264 bool
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{true, true, []string{"hevc_qsv", "h264_qsv", "libx264"}},
|
||||||
|
{false, true, []string{"h264_qsv", "libx264"}},
|
||||||
|
{true, false, []string{"hevc_qsv", "libx264"}},
|
||||||
|
{false, false, []string{"libx264"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := encoderCandidates(c.hevc, c.h264)
|
||||||
|
if strings.Join(got, ",") != strings.Join(c.want, ",") {
|
||||||
|
t.Errorf("hevc=%v h264=%v 应为 %v,实际 %v", c.hevc, c.h264, c.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 候选列表必须能真的跑出结果,最后一个永远是纯 CPU 的 libx264
|
||||||
|
last := encoderCandidates(true, true)
|
||||||
|
if last[len(last)-1] != "libx264" {
|
||||||
|
t.Errorf("最后一个候选必须是 libx264,实际 %s", last[len(last)-1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编码器参数:固定码率 / 质量模式,HEVC 必须带 hvc1 tag(Apple 系才认)
|
||||||
|
func TestEncoderOpts(t *testing.T) {
|
||||||
|
join := func(ss []string) string { return strings.Join(ss, " ") }
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
encoder string
|
||||||
|
bitrate int
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"hevc_qsv", 4000, []string{"-b:v", "4000k", "-tag:v", "hvc1"}},
|
||||||
|
{"h264_qsv", 4000, []string{"-b:v", "4000k"}},
|
||||||
|
{"libx264", 4000, []string{"-b:v", "4000k", "-preset", "medium"}},
|
||||||
|
{"hevc_qsv", 0, []string{"-global_quality", "22", "-preset", "medium", "-tag:v", "hvc1"}},
|
||||||
|
{"libx265", 0, []string{"-crf", "18", "-preset", "medium", "-tag:v", "hvc1"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := encoderOpts(c.encoder, c.bitrate)
|
||||||
|
if join(got) != join(c.want) {
|
||||||
|
t.Errorf("%s @ %d 应为 %v,实际 %v", c.encoder, c.bitrate, c.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 「合成码率」填 0 时的算法:源片码率 + 弹幕余量,探测不到就交回 0(走质量模式)
|
||||||
|
func TestAutoMergeBitrate(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
source int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{3197, 3697}, // 用户那场录播的源片码率
|
||||||
|
{0, 0}, // 探测不到 → 质量模式
|
||||||
|
{-1, 0},
|
||||||
|
{100, 800}, // 低于下限时抬到下限
|
||||||
|
{3000, 3500}, // 正常情况就是 +500
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := autoMergeBitrate(c.source); got != c.want {
|
||||||
|
t.Errorf("源码率 %d 应得 %d,实际 %d", c.source, c.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ffmpeg 输出的解析:分辨率靠「逗号 + 数字x数字」,码率靠视频流那一行的 kb/s,
|
||||||
|
// 视频流没写码率时退回容器总码率
|
||||||
|
func TestProbeStreamRegex(t *testing.T) {
|
||||||
|
sizeLine := " Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(tv, bt709, progressive), 1920x1080 [SAR 1:1 DAR 16:9], 3217 kb/s, 23.98 fps"
|
||||||
|
m := videoSizeRe.FindStringSubmatch(sizeLine)
|
||||||
|
if len(m) != 3 {
|
||||||
|
t.Fatalf("没解析出分辨率:%s", sizeLine)
|
||||||
|
}
|
||||||
|
w, _ := strconv.Atoi(m[1])
|
||||||
|
h, _ := strconv.Atoi(m[2])
|
||||||
|
if w != 1920 || h != 1080 {
|
||||||
|
t.Errorf("分辨率解析成 %dx%d", w, h)
|
||||||
|
}
|
||||||
|
// 视频流那一行里的码率(不是编码器名里的 0x…)
|
||||||
|
if b := videoBitrateRe.FindStringSubmatch(sizeLine); len(b) != 2 || b[1] != "3217" {
|
||||||
|
t.Errorf("视频流码率没解析对:%v", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
portrait := " Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(tv, smpte170m/unknown/unknown, progressive), 720x1280 [SAR 1:1 DAR 9:16], 1570 kb/s, 10 fps"
|
||||||
|
m = videoSizeRe.FindStringSubmatch(portrait)
|
||||||
|
if len(m) != 3 || m[1] != "720" || m[2] != "1280" {
|
||||||
|
t.Errorf("竖屏分辨率没解析对:%v", m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频流那行没写码率时用容器总码率兜底
|
||||||
|
durLine := " Duration: 00:02:55.07, start: 0.000000, bitrate: 1767 kb/s"
|
||||||
|
if m := containerBitrateRe.FindStringSubmatch(durLine); len(m) != 2 || m[1] != "1767" {
|
||||||
|
t.Errorf("容器码率没解析对:%v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -258,22 +258,6 @@ func getLiveID(uid int) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据 uid 查看主播是否正在直播
|
|
||||||
func isLiveOn(uid int) bool {
|
|
||||||
liveRooms.RLock()
|
|
||||||
_, ok := liveRooms.rooms[uid]
|
|
||||||
liveRooms.RUnlock()
|
|
||||||
if ok {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if isLive, room, err := tryFetchLiveInfo(uid); err == nil {
|
|
||||||
defer liveRoomPool.Put(room)
|
|
||||||
return isLive
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取主播直播间的标题
|
// 获取主播直播间的标题
|
||||||
func (s *streamer) getTitle() string {
|
func (s *streamer) getTitle() string {
|
||||||
return getTitle(s.UID)
|
return getTitle(s.UID)
|
||||||
|
|||||||
+1
-1
@@ -24,5 +24,5 @@ func transFilename(filename string) string {
|
|||||||
}
|
}
|
||||||
filename = filename[:cut]
|
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]`)
|
re := regexp.MustCompile(`[<>:"/\\|?*\r\n]`)
|
||||||
filename = re.ReplaceAllString(filename, "-")
|
filename = re.ReplaceAllString(filename, "-")
|
||||||
dir := recordingDir()
|
dir := recordSaveDir()
|
||||||
outFilename := filepath.Join(dir, filename)
|
outFilename := filepath.Join(dir, filename)
|
||||||
if utf8.RuneCountInString(outFilename) <= maxPathRunes {
|
if utf8.RuneCountInString(outFilename) <= maxPathRunes {
|
||||||
return outFilename
|
return outFilename
|
||||||
|
|||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 输出帧率只在目标的**上面**才生效:源片本来就 60fps 时填 30 只会把画面压得更卡,
|
||||||
|
// 源片帧率探测不到(0)时也不动,免得把高帧率源押到某个值
|
||||||
|
func TestOutputFpsOnlyRaises(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
target int
|
||||||
|
source float64
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{0, 30, 0}, // 没填:跟随源片
|
||||||
|
{60, 0, 0}, // 源片帧率探测不到:不动
|
||||||
|
{60, 30, 60}, // 15/24/30fps 录播里弹幕一格一格跳,提上去
|
||||||
|
{60, 60, 0}, // 已经一样高:不动
|
||||||
|
{60, 59.94, 60}, // 29.97/59.94 这类小数帧率要当成「低一点点」
|
||||||
|
{30, 60, 0}, // 降帧没意义
|
||||||
|
{-1, 30, 0}, // 非法值当成没填
|
||||||
|
} {
|
||||||
|
if got := outputFps(c.target, c.source); got != c.want {
|
||||||
|
t.Errorf("outputFps(%d, %g) = %d,期望 %d", c.target, c.source, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 透明头像层模式没有源片可跟:设置填了就按填的,没填固定 30
|
||||||
|
func TestLayerFpsFallback(t *testing.T) {
|
||||||
|
if got := layerFps(0); got != defaultLayerFps {
|
||||||
|
t.Errorf("layerFps(0) = %d,期望 %d", got, defaultLayerFps)
|
||||||
|
}
|
||||||
|
if got := layerFps(60); got != 60 {
|
||||||
|
t.Errorf("layerFps(60) = %d,期望 60", got)
|
||||||
|
}
|
||||||
|
// 手工构造的 burnContext 没填这个字段时也不能把 r=0 交给 ffmpeg
|
||||||
|
// (color 源拿 r=0 会直接 EINVAL,症状是 exit status 0xffffffea)
|
||||||
|
ctx := &burnContext{}
|
||||||
|
if got := ctx.layerRate(); got != defaultLayerFps {
|
||||||
|
t.Errorf("没填 layerFps 的 burnContext 应当回落到 %d,实际 %d", defaultLayerFps, got)
|
||||||
|
}
|
||||||
|
ctx.layerFps = 25
|
||||||
|
if got := ctx.layerRate(); got != 25 {
|
||||||
|
t.Errorf("填了 layerFps 就按填的来,实际 %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 低帧率源设了输出帧率时,成片帧率真的会提上去:
|
||||||
|
// fps 滤镜必须加在字幕/头像之前,弹幕才会按新帧率重绘;
|
||||||
|
// 加在之后只是把已经画好的几帧复制一遍,看起来还是卡
|
||||||
|
func TestRenderChunkRaisesFrameRate(t *testing.T) {
|
||||||
|
ffmpegFile := getFFmpeg()
|
||||||
|
if ffmpegFile == "" {
|
||||||
|
t.Skip("找不到 ffmpeg,跳过")
|
||||||
|
}
|
||||||
|
withJobFlags(t, true, false, false)
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
src := filepath.Join(dir, "src.mp4")
|
||||||
|
if err := runFFmpegWith(ffmpegFile, []string{
|
||||||
|
"-y", "-f", "lavfi", "-i", "color=c=black:s=160x120:d=1:r=15",
|
||||||
|
"-c:v", "libx264", "-pix_fmt", "yuv420p", src,
|
||||||
|
}, dir, nil); err != nil {
|
||||||
|
t.Skipf("生成 15fps 测试视频失败,跳过:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunk := danmuChunk{start: 0, end: 1, records: []danmuRecord{
|
||||||
|
{Time: 0.1, UID: 7, Name: "甲", Content: "提帧测试", Lane: 0, Width: 100},
|
||||||
|
}}
|
||||||
|
ctx := &burnContext{
|
||||||
|
ffmpegFile: ffmpegFile,
|
||||||
|
videoFile: src,
|
||||||
|
outFile: filepath.Join(dir, "out.mp4"),
|
||||||
|
meta: danmuMeta{PlayResX: 160, PlayResY: 120, FontSize: 20},
|
||||||
|
avatars: map[int64]string{},
|
||||||
|
dir: dir,
|
||||||
|
encoder: "libx264",
|
||||||
|
outFps: outputFps(30, 15),
|
||||||
|
}
|
||||||
|
if ctx.outFps != 30 {
|
||||||
|
t.Fatalf("15fps 源设 30 应当产出 outFps=30,实际 %d", ctx.outFps)
|
||||||
|
}
|
||||||
|
|
||||||
|
assName := chunkTempPrefix + "fps_test.ass"
|
||||||
|
if err := writeChunkAss(filepath.Join(dir, assName), ctx.meta, chunk, 0,
|
||||||
|
avatarSize(ctx.meta.FontSize), avatarGap(ctx.meta.FontSize)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := filepath.Join(dir, "chunk.mp4")
|
||||||
|
if err := ctx.renderChunk(chunk, assName, out, true, nil); err != nil {
|
||||||
|
t.Fatalf("提帧渲染不该失败:%v", err)
|
||||||
|
}
|
||||||
|
info := probeVideoStream(ffmpegFile, out)
|
||||||
|
if math.Abs(info.fps-30) > 0.5 {
|
||||||
|
t.Fatalf("成片帧率应当是 30,实际 %.2f", info.fps)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
module github.com/orzogc/acfunlive
|
module github.com/orzogc/acfunlive
|
||||||
|
|
||||||
go 1.26.0
|
go 1.27.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.13.0
|
github.com/PuerkitoBio/goquery v1.13.0
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 挂起进程:SIGSTOP,进程立刻停止占用 CPU,SIGCONT 后从原地继续
|
||||||
|
func suspendProcess(p *os.Process) error {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return syscall.Kill(p.Pid, syscall.SIGSTOP)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumeProcess(p *os.Process) error {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return syscall.Kill(p.Pid, syscall.SIGCONT)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PROCESS_SUSPEND_RESUME:挂起 / 恢复进程所需的访问权限
|
||||||
|
const processSuspendResume = 0x0800
|
||||||
|
|
||||||
|
var (
|
||||||
|
ntdllDLL = syscall.NewLazyDLL("ntdll.dll")
|
||||||
|
procSuspend = ntdllDLL.NewProc("NtSuspendProcess")
|
||||||
|
procResume = ntdllDLL.NewProc("NtResumeProcess")
|
||||||
|
)
|
||||||
|
|
||||||
|
// 挂起进程。Windows 没有公开的 Win32 API,用 ntdll 的 NtSuspendProcess,
|
||||||
|
// 效果等同任务管理器里的「挂起」:进程不再占用 CPU,恢复后从原地接着跑。
|
||||||
|
func suspendProcess(p *os.Process) error {
|
||||||
|
return ntProcessCall(procSuspend, "NtSuspendProcess", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumeProcess(p *os.Process) error {
|
||||||
|
return ntProcessCall(procResume, "NtResumeProcess", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ntProcessCall(proc *syscall.LazyProc, name string, p *os.Process) error {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
h, err := syscall.OpenProcess(processSuspendResume, false, uint32(p.Pid))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("打开进程 %d 失败:%v", p.Pid, err)
|
||||||
|
}
|
||||||
|
defer syscall.CloseHandle(h)
|
||||||
|
// NTSTATUS 为 0 表示成功
|
||||||
|
if ret, _, _ := proc.Call(uintptr(h)); ret != 0 {
|
||||||
|
return fmt.Errorf("%s 失败,NTSTATUS=0x%x", name, ret)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+194
@@ -87,6 +87,200 @@ func TestRelayoutWidthScalesWithFontSize(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 多分段(断流重连)拼接时,remapJsonlTimes 只改时间、保留各段自己算的弹道,
|
||||||
|
// 两段各自都从 0 号弹道开始排,时间压缩到同一条轴之后必然互相压。重排按拼接后
|
||||||
|
// 的时间轴统一分配,这种跨段冲突也会一并消掉。
|
||||||
|
func TestRelayoutFixesConcatenatedSegmentConflict(t *testing.T) {
|
||||||
|
meta := danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 48}
|
||||||
|
mk := func(base float64) []danmuRecord {
|
||||||
|
var out []danmuRecord
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
out = append(out, danmuRecord{
|
||||||
|
Time: base + float64(i)*0.4,
|
||||||
|
UID: int64(3000 + i),
|
||||||
|
Name: "观众",
|
||||||
|
Content: "断流前后各自的弹幕",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
// 两段各自从 0 秒开始排(录制时就是这样),拼接后第二段紧接第一段
|
||||||
|
first := mk(0)
|
||||||
|
relayoutLanes(first, meta)
|
||||||
|
second := mk(4.8)
|
||||||
|
relayoutLanes(second, meta)
|
||||||
|
records := append(append([]danmuRecord{}, first...), second...)
|
||||||
|
|
||||||
|
// 前提:这种拼法本来就撞(第二段的 0 号弹道紧接第一段的 0 号弹道)
|
||||||
|
if overlaps := countLaneOverlaps(records, meta); overlaps == 0 {
|
||||||
|
t.Fatal("两段直接拼起来本应有同弹道冲突,用例前提不成立")
|
||||||
|
}
|
||||||
|
|
||||||
|
relayoutLanes(records, meta)
|
||||||
|
|
||||||
|
if overlaps := countLaneOverlaps(records, meta); overlaps != 0 {
|
||||||
|
t.Errorf("重排后仍有 %d 条同弹道冲突", overlaps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 画布短边:横屏取高、竖屏取宽
|
||||||
|
func TestShortSide(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
w, h, want int
|
||||||
|
}{
|
||||||
|
{1920, 1080, 1080},
|
||||||
|
{1080, 1920, 1080},
|
||||||
|
{720, 1280, 720},
|
||||||
|
{1280, 720, 720},
|
||||||
|
{1080, 1080, 1080},
|
||||||
|
{0, 1080, 0},
|
||||||
|
{1920, -1, 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := shortSide(c.w, c.h); got != c.want {
|
||||||
|
t.Errorf("shortSide(%d,%d) 应为 %d,实际 %d", c.w, c.h, c.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动字号 = 画布短边的 5.56%。竖屏必须按宽度算 ——
|
||||||
|
// 按高度算的话 720x1280 会得到 71,一条弹幕就铺满整行。
|
||||||
|
func TestAutoFontSize(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
vw, vh, want int
|
||||||
|
}{
|
||||||
|
{1920, 1080, 60},
|
||||||
|
{1280, 720, 40},
|
||||||
|
{960, 540, 30},
|
||||||
|
{720, 1280, 40},
|
||||||
|
{640, 1280, 36},
|
||||||
|
{1080, 1920, 60},
|
||||||
|
{1080, 1080, 60}, // 方形按宽算
|
||||||
|
{0, 1080, 0}, // 拿不到尺寸
|
||||||
|
{1920, 0, 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := autoFontSize(c.vw, c.vh); got != c.want {
|
||||||
|
t.Errorf("autoFontSize(%d,%d) 应为 %d,实际 %d", c.vw, c.vh, c.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 录制端的档位预设必须和自动字号规则一致,否则录出来的弹幕文件和成片对不上
|
||||||
|
func TestSubConfigsMatchAutoFontSize(t *testing.T) {
|
||||||
|
for key, cfg := range subConfigs {
|
||||||
|
want := autoFontSize(cfg.PlayResX, cfg.PlayResY)
|
||||||
|
if cfg.FontSize != want {
|
||||||
|
t.Errorf("档位 %d(%dx%d)字号应为 %d,实际 %d",
|
||||||
|
key, cfg.PlayResX, cfg.PlayResY, want, cfg.FontSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主流程:先把坐标系统一到视频分辨率,再按画布定字号
|
||||||
|
func TestApplyFontSize(t *testing.T) {
|
||||||
|
// 横屏弹幕文件 + 竖屏成片:以前按高度比会放大到 71,现在按短边取 40
|
||||||
|
meta := danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 60}
|
||||||
|
unifyDanmuSpace(&meta, 720, 1280)
|
||||||
|
applyFontSize(&meta, 0)
|
||||||
|
if meta.FontSize != 40 {
|
||||||
|
t.Errorf("竖屏成片字号应为 40,实际 %d", meta.FontSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 横屏成片和弹幕文件一致时保持原样
|
||||||
|
meta = danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 60}
|
||||||
|
unifyDanmuSpace(&meta, 1920, 1080)
|
||||||
|
applyFontSize(&meta, 0)
|
||||||
|
if meta.FontSize != 60 {
|
||||||
|
t.Errorf("1080p 横屏字号应为 60,实际 %d", meta.FontSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 竖屏弹幕文件 + 同尺寸竖屏成片:老文件写的 60 要归一到 40
|
||||||
|
meta = danmuMeta{PlayResX: 720, PlayResY: 1280, FontSize: 60}
|
||||||
|
unifyDanmuSpace(&meta, 720, 1280)
|
||||||
|
applyFontSize(&meta, 0)
|
||||||
|
if meta.FontSize != 40 {
|
||||||
|
t.Errorf("竖屏弹幕文件应归一到 40,实际 %d", meta.FontSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置里填了数就按填的来(成片像素)
|
||||||
|
meta = danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 60}
|
||||||
|
unifyDanmuSpace(&meta, 720, 1280)
|
||||||
|
applyFontSize(&meta, 33)
|
||||||
|
if meta.FontSize != 33 {
|
||||||
|
t.Errorf("填了 33 就该用 33,实际 %d", meta.FontSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拿不到画布尺寸时保留弹幕文件里的字号
|
||||||
|
meta = danmuMeta{FontSize: 48}
|
||||||
|
applyFontSize(&meta, 0)
|
||||||
|
if meta.FontSize != 48 {
|
||||||
|
t.Errorf("拿不到尺寸时应保留 48,实际 %d", meta.FontSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 低分辨率也别算出 0 号字
|
||||||
|
meta = danmuMeta{PlayResX: 4, PlayResY: 4, FontSize: 30}
|
||||||
|
applyFontSize(&meta, 0)
|
||||||
|
if meta.FontSize < 1 {
|
||||||
|
t.Errorf("字号不能小于 1,实际 %d", meta.FontSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹幕坐标系(PlayRes)和视频分辨率不一致时统一到视频坐标系;
|
||||||
|
// 字号不在这里改,由 applyFontSize 按最终画布决定
|
||||||
|
func TestUnifyDanmuSpace(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
resX, resY, fontSize int
|
||||||
|
vw, vh int
|
||||||
|
wantChanged bool
|
||||||
|
}{
|
||||||
|
{"坐标系一致不动", 1920, 1080, 60, 1920, 1080, false},
|
||||||
|
{"960x540 弹幕配 720x1280 视频", 960, 540, 30, 720, 1280, true},
|
||||||
|
{"探测不到视频尺寸就不动", 960, 540, 30, 0, 0, false},
|
||||||
|
{"相同宽高比但不同分辨率也要按视频走", 1280, 720, 40, 1920, 1080, true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
meta := danmuMeta{PlayResX: c.resX, PlayResY: c.resY, FontSize: c.fontSize}
|
||||||
|
changed := unifyDanmuSpace(&meta, c.vw, c.vh)
|
||||||
|
if changed != c.wantChanged {
|
||||||
|
t.Errorf("%s:changed 应为 %v,实际 %v", c.name, c.wantChanged, changed)
|
||||||
|
}
|
||||||
|
if meta.FontSize != c.fontSize {
|
||||||
|
t.Errorf("%s:统一坐标系不该动字号,应为 %d,实际 %d", c.name, c.fontSize, meta.FontSize)
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
if meta.PlayResX != c.vw || meta.PlayResY != c.vh {
|
||||||
|
t.Errorf("%s:PlayRes 应为 %dx%d,实际 %dx%d", c.name, c.vw, c.vh, meta.PlayResX, meta.PlayResY)
|
||||||
|
}
|
||||||
|
// 统一之后头像尺寸和文字都在视频坐标系里,必须能对上
|
||||||
|
if avatarSize(meta.FontSize) <= 0 {
|
||||||
|
t.Errorf("%s:头像直径算出来是 %d", c.name, avatarSize(meta.FontSize))
|
||||||
|
}
|
||||||
|
} else if meta.PlayResX != c.resX || meta.PlayResY != c.resY {
|
||||||
|
t.Errorf("%s:不该改的时候改了 PlayRes", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按渲染时的公式统计同弹道追尾条数
|
||||||
|
func countLaneOverlaps(records []danmuRecord, meta danmuMeta) int {
|
||||||
|
show := int64(danmuShowSeconds * 1e9)
|
||||||
|
resX := int64(meta.PlayResX)
|
||||||
|
last := map[int]int64{} // 弹道 → 上一条的让位时刻
|
||||||
|
over := 0
|
||||||
|
for _, r := range records {
|
||||||
|
total := int64(avatarSize(meta.FontSize)+avatarGap(meta.FontSize)) + int64(r.Width)
|
||||||
|
rel := int64(r.Time * 1e9)
|
||||||
|
emerge := rel + total*show/(resX+total)
|
||||||
|
if prev, ok := last[r.Lane]; ok && rel < prev {
|
||||||
|
over++
|
||||||
|
}
|
||||||
|
last[r.Lane] = emerge
|
||||||
|
}
|
||||||
|
return over
|
||||||
|
}
|
||||||
|
|
||||||
// 密集弹幕(弹道信息全是坏的:lane 都是 0、width 都是 0)重排后不应再有追尾,
|
// 密集弹幕(弹道信息全是坏的:lane 都是 0、width 都是 0)重排后不应再有追尾,
|
||||||
// 也不该超过弹道行数上限
|
// 也不该超过弹道行数上限
|
||||||
func TestRelayoutNoOverlapOnDenseInput(t *testing.T) {
|
func TestRelayoutNoOverlapOnDenseInput(t *testing.T) {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func argsHandle() {
|
|||||||
burnOut := flag.String("out", "", "合成输出文件路径,默认与弹幕文件同目录")
|
burnOut := flag.String("out", "", "合成输出文件路径,默认与弹幕文件同目录")
|
||||||
burnOffset := flag.Float64("offset", 0, "录播视频相对弹幕录制开始的时间偏移,单位秒,配合 -danmuburn -video 使用")
|
burnOffset := flag.Float64("offset", 0, "录播视频相对弹幕录制开始的时间偏移,单位秒,配合 -danmuburn -video 使用")
|
||||||
burnFFmpeg := flag.String("ffmpeg", "", "ffmpeg 可执行文件路径;不指定时读设置文件里的 ffmpegPath,仍旧找不到再按常见位置查找")
|
burnFFmpeg := flag.String("ffmpeg", "", "ffmpeg 可执行文件路径;不指定时读设置文件里的 ffmpegPath,仍旧找不到再按常见位置查找")
|
||||||
burnBitrate = *flag.Int("vbitrate", 0, "合成视频的目标码率(Kbps),0 为默认质量,配合 -danmuburn 使用")
|
burnBitrate = *flag.Int("vbitrate", 0, "合成视频的目标码率(Kbps),0 为按「源片码率 + 500」自动计算,配合 -danmuburn 使用")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if *isHelp {
|
if *isHelp {
|
||||||
@@ -106,6 +106,8 @@ func initialize() {
|
|||||||
|
|
||||||
loadConfig()
|
loadConfig()
|
||||||
checkConfig()
|
checkConfig()
|
||||||
|
// 上次在合并途中被关掉的话,录播目录和成片目录里会留下临时文件,启动时清一次
|
||||||
|
cleanLeftoverTempFiles()
|
||||||
ensureAuthToken()
|
ensureAuthToken()
|
||||||
buildStreamers()
|
buildStreamers()
|
||||||
|
|
||||||
|
|||||||
+277
-22
@@ -27,13 +27,39 @@ var mergeVideoExts = map[string]struct{}{
|
|||||||
var mergeDanmuExts = map[string]struct{}{".jsonl": {}, ".ass": {}}
|
var mergeDanmuExts = map[string]struct{}{".jsonl": {}, ".ass": {}}
|
||||||
|
|
||||||
// 手动合并的请求体
|
// 手动合并的请求体
|
||||||
|
//
|
||||||
|
// 两种写法:只填 videos/danmu 是单个任务(老样子);填 jobs 是一次排多个任务,
|
||||||
|
// 每个任务自带视频、弹幕和合成参数,服务端按顺序串行合并
|
||||||
type mergeRequest struct {
|
type mergeRequest struct {
|
||||||
Videos []string `json:"videos"` // 视频文件,可多个(断流分段):文件名或绝对/相对路径
|
Videos []string `json:"videos"` // 视频文件,可多个(断流分段):文件名或绝对/相对路径
|
||||||
Danmu string `json:"danmu"` // 弹幕文件:.jsonl 直接用,.ass / .nick.ass 先还原成 jsonl
|
Danmu string `json:"danmu"` // 弹幕文件:.jsonl 直接用,.ass / .nick.ass 先还原成 jsonl
|
||||||
Offset *float64 `json:"offset"` // 手动指定的偏移(秒),不填则按文件名里的录制时间推算
|
Offset *float64 `json:"offset"` // 手动指定的偏移(秒),不填则按文件名里的录制时间推算
|
||||||
OutName string `json:"outName"` // 输出文件名,留空用默认名且自动避开重名
|
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 {
|
type mergeVideoInfo struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -65,6 +91,7 @@ type mergePlan struct {
|
|||||||
out string // 成片的绝对路径
|
out string // 成片的绝对路径
|
||||||
meta danmuMeta
|
meta danmuMeta
|
||||||
offset *float64
|
offset *float64
|
||||||
|
opts mergeOptions // 这一组自己的合成参数
|
||||||
info mergeInfo
|
info mergeInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +102,10 @@ var mergeRun struct {
|
|||||||
outName string
|
outName string
|
||||||
lastErr string
|
lastErr string
|
||||||
doneAt int64
|
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
|
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) {
|
func resolveMergeInput(value string) (string, error) {
|
||||||
value = strings.TrimSpace(value)
|
value = strings.TrimSpace(value)
|
||||||
@@ -119,6 +159,52 @@ func resolveMergeInput(value string) (string, error) {
|
|||||||
return "", fmt.Errorf("找不到文件:%s", value)
|
return "", fmt.Errorf("找不到文件:%s", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 找同名弹幕文件时按这个顺序试:本程序录的 .jsonl 带头像链接,优先用
|
||||||
|
var mergeDanmuCandidates = []string{".jsonl", ".ass", ".nick.ass"}
|
||||||
|
|
||||||
|
// 按视频文件找同目录下的同名弹幕文件,找不到返回空字符串。
|
||||||
|
// 同目录找不到时再拿文件名去录播目录里试一次(视频被挪走、弹幕还留在录播目录的情况)
|
||||||
|
func findDanmuForVideo(video string) string {
|
||||||
|
full, err := resolveMergeInput(video)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
base := strings.TrimSuffix(full, filepath.Ext(full))
|
||||||
|
dirs := []string{filepath.Dir(full)}
|
||||||
|
if recDir := recordSaveDir(); normalizePath(recDir) != normalizePath(filepath.Dir(full)) {
|
||||||
|
dirs = append(dirs, recDir)
|
||||||
|
}
|
||||||
|
for _, dir := range dirs {
|
||||||
|
for _, ext := range mergeDanmuCandidates {
|
||||||
|
cand := filepath.Join(dir, filepath.Base(base)+ext)
|
||||||
|
if info, err := os.Stat(cand); err == nil && !info.IsDir() {
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填回输入框时的写法:录播目录/成片目录里的只写文件名,别处的写绝对路径(和浏览弹窗一致)
|
||||||
|
func mergeFillValue(path string) string {
|
||||||
|
dir := normalizePath(filepath.Dir(path))
|
||||||
|
if dir == normalizePath(recordSaveDir()) || dir == normalizePath(mergeSaveDir()) {
|
||||||
|
return filepath.Base(path)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/merge/danmu?video=xxx —— 页面选好视频后自动找同名弹幕文件用
|
||||||
|
func mergeDanmuMatchHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := findDanmuForVideo(r.URL.Query().Get("video"))
|
||||||
|
resp := map[string]string{"danmu": ""}
|
||||||
|
if path != "" {
|
||||||
|
resp["danmu"] = mergeFillValue(path)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
// 扩展名白名单的说明文字
|
// 扩展名白名单的说明文字
|
||||||
func extListText(m map[string]struct{}) string {
|
func extListText(m map[string]struct{}) string {
|
||||||
out := make([]string, 0, len(m))
|
out := make([]string, 0, len(m))
|
||||||
@@ -205,6 +291,9 @@ type danmuSource struct {
|
|||||||
last float64
|
last float64
|
||||||
hasAvatar bool
|
hasAvatar bool
|
||||||
source string // 来源说明
|
source string // 来源说明
|
||||||
|
// 从 .ass 还原出来的结果写不进原目录、退到系统临时目录时置位:
|
||||||
|
// 这种文件调用方用完必须删掉,别在临时目录里越攒越多
|
||||||
|
tempJsonl bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepareDanmuSource(path string, write bool) (danmuSource, error) {
|
func prepareDanmuSource(path string, write bool) (danmuSource, error) {
|
||||||
@@ -229,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 {
|
if err != nil {
|
||||||
return danmuSource{}, err
|
return danmuSource{}, err
|
||||||
}
|
}
|
||||||
@@ -240,6 +329,12 @@ func prepareDanmuSource(path string, write bool) (danmuSource, error) {
|
|||||||
return src, nil
|
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 {
|
if err := writeJsonlFile(jsonlPath, meta, records); err != nil {
|
||||||
// 目录只读等情况退到临时目录,不影响这次合并
|
// 目录只读等情况退到临时目录,不影响这次合并
|
||||||
tmp := filepath.Join(os.TempDir(), chunkTempPrefix+"converted.jsonl")
|
tmp := filepath.Join(os.TempDir(), chunkTempPrefix+"converted.jsonl")
|
||||||
@@ -248,12 +343,31 @@ func prepareDanmuSource(path string, write bool) (danmuSource, error) {
|
|||||||
}
|
}
|
||||||
lPrintWarnf("无法把还原结果写入 %s(%v),改用临时文件 %s", jsonlPath, err, tmp)
|
lPrintWarnf("无法把还原结果写入 %s(%v),改用临时文件 %s", jsonlPath, err, tmp)
|
||||||
src.jsonlPath = tmp
|
src.jsonlPath = tmp
|
||||||
|
src.tempJsonl = true
|
||||||
|
lPrintf("%s,写成 %s", summary, filepath.Base(tmp))
|
||||||
|
logAssBadLines(path, bad)
|
||||||
return src, nil
|
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
|
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) {
|
||||||
|
if src.tempJsonl {
|
||||||
|
_ = os.Remove(src.jsonlPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 输出文件名只能是文件名本身,且固定为 mp4
|
// 输出文件名只能是文件名本身,且固定为 mp4
|
||||||
func sanitizeOutName(name string) (string, error) {
|
func sanitizeOutName(name string) (string, error) {
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
@@ -285,9 +399,44 @@ func freeOutPath(dir, base string) string {
|
|||||||
return cand
|
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) {
|
func buildMergePlan(req mergeRequest) (mergePlan, error) {
|
||||||
var plan mergePlan
|
var plan mergePlan
|
||||||
|
ffmpegFile := getFFmpeg()
|
||||||
|
plan.opts = req.Options
|
||||||
|
if msg := req.Options.validate(ffmpegFile); msg != "" {
|
||||||
|
return plan, errors.New(msg)
|
||||||
|
}
|
||||||
|
|
||||||
// 视频:一行一个,允许夹带空行
|
// 视频:一行一个,允许夹带空行
|
||||||
var values []string
|
var values []string
|
||||||
@@ -357,7 +506,6 @@ func buildMergePlan(req mergeRequest) (mergePlan, error) {
|
|||||||
DanmuHasAvatar: src.hasAvatar,
|
DanmuHasAvatar: src.hasAvatar,
|
||||||
Source: src.source,
|
Source: src.source,
|
||||||
}
|
}
|
||||||
ffmpegFile := getFFmpeg()
|
|
||||||
for _, f := range plan.videos {
|
for _, f := range plan.videos {
|
||||||
fi, err := os.Stat(f)
|
fi, err := os.Stat(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -402,7 +550,9 @@ func buildMergePlan(req mergeRequest) (mergePlan, error) {
|
|||||||
if info.Duration > 0 && src.last+info.Offset >= info.Duration {
|
if info.Duration > 0 && src.last+info.Offset >= info.Duration {
|
||||||
info.Warnings = append(info.Warnings, fmt.Sprintf(
|
info.Warnings = append(info.Warnings, fmt.Sprintf(
|
||||||
"最后一条弹幕在 %.1f 秒,加上偏移后已超出视频总长 %.1f 秒,超出的弹幕会被丢掉;"+
|
"最后一条弹幕在 %.1f 秒,加上偏移后已超出视频总长 %.1f 秒,超出的弹幕会被丢掉;"+
|
||||||
"如果这个视频只是整场的一段,请把同场的其它分段一起加进来",
|
"如果这个视频只是整场的一段,请把同场的其它分段一起加进来;"+
|
||||||
|
"如果弹幕整体都落在时长之外,通常是这个弹幕文件的时间基准和视频不一致"+
|
||||||
|
"(合成时会按第一条弹幕自动对齐,日志里有平移量),也可以自己填「偏移」",
|
||||||
src.last+info.Offset, info.Duration))
|
src.last+info.Offset, info.Duration))
|
||||||
}
|
}
|
||||||
if info.Duration == 0 {
|
if info.Duration == 0 {
|
||||||
@@ -413,17 +563,32 @@ func buildMergePlan(req mergeRequest) (mergePlan, error) {
|
|||||||
return plan, nil
|
return plan, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 真正执行合并,进度直接打到运行日志里
|
// 真正执行合并,进度既打到运行日志里,也同步给 web UI 的任务卡片。
|
||||||
func runManualMerge(plan mergePlan) error {
|
// idx / total 是批量队列里的位置(单任务传 1, 1),只用来在日志和界面上标「第几个」
|
||||||
|
func runManualMerge(plan mergePlan, idx, total int) error {
|
||||||
ffmpegFile := getFFmpeg()
|
ffmpegFile := getFFmpeg()
|
||||||
if ffmpegFile == "" {
|
if ffmpegFile == "" {
|
||||||
return errors.New("找不到 ffmpeg,请先在设置里填写 ffmpeg 路径")
|
return errors.New("找不到 ffmpeg,请先在设置里填写 ffmpeg 路径")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 先把任务的输入大小和总时长登记下来,界面在 ffmpeg 跑起来之前就有东西可显示
|
||||||
|
inputBytes := int64(0)
|
||||||
|
for _, v := range plan.info.Videos {
|
||||||
|
inputBytes += v.Size
|
||||||
|
}
|
||||||
|
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)
|
src, err := prepareDanmuSource(plan.danmu, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// 还原结果写不进原目录时用的是系统临时目录里的文件,合并完就删掉
|
||||||
|
defer dropTempDanmu(src)
|
||||||
|
|
||||||
// 每一段的录制开始时刻取自文件名,用来算偏移和拼接后的时间轴
|
// 每一段的录制开始时刻取自文件名,用来算偏移和拼接后的时间轴
|
||||||
segs := make([]recordSegment, 0, len(plan.videos))
|
segs := make([]recordSegment, 0, len(plan.videos))
|
||||||
@@ -450,12 +615,12 @@ func runManualMerge(plan mergePlan) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 复用自动合并那条链路:多段会先拼接并把弹幕时间重映射到拼接后的时间轴
|
// 复用自动合并那条链路:多段会先拼接并把弹幕时间重映射到拼接后的时间轴
|
||||||
video, jsonl, autoOffset, cleanup, err := prepareForMerge(ffmpegFile, segs, src.jsonlPath, danmuStartNs)
|
// (手动合并不删源视频,所以这里用不上「实际参与合并的文件」)
|
||||||
|
video, jsonl, autoOffset, _, cleanup, err := prepareForMerge(ffmpegFile, segs, src.jsonlPath, danmuStartNs)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
offset, note := autoOffset, "按文件名里的录制时间自动推算"
|
offset, note := autoOffset, "按文件名里的录制时间自动推算"
|
||||||
if plan.offset != nil {
|
if plan.offset != nil {
|
||||||
offset, note = *plan.offset, "按你填写的偏移"
|
offset, note = *plan.offset, "按你填写的偏移"
|
||||||
@@ -474,41 +639,71 @@ func runManualMerge(plan mergePlan) error {
|
|||||||
// 成片目录可能是网络盘或外接盘,直接往里写、写到一半失败会在那里留个半成品
|
// 成片目录可能是网络盘或外接盘,直接往里写、写到一半失败会在那里留个半成品
|
||||||
tmpOut := tempMergeOutput(plan.danmu, filepath.Base(plan.out))
|
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)
|
lPrintf("先在本地合成 %s,完成后落到 %s", filepath.Base(tmpOut), plan.out)
|
||||||
|
|
||||||
// 标记成正在写入,避免在文件管理里把还没写完的成片删掉
|
// 标记成正在写入,避免在文件管理里把还没写完的成片删掉
|
||||||
markFileActive(tmpOut, true)
|
markFileActive(tmpOut, true)
|
||||||
burnErr := burnDanmu(jsonl, video, tmpOut, offset)
|
burnErr := burnDanmu(jsonl, video, tmpOut, offset, plan.opts)
|
||||||
markFileActive(tmpOut, false)
|
markFileActive(tmpOut, false)
|
||||||
if burnErr != nil {
|
if burnErr != nil {
|
||||||
_ = os.Remove(tmpOut)
|
_ = os.Remove(tmpOut)
|
||||||
return burnErr
|
return burnErr
|
||||||
}
|
}
|
||||||
|
jobSetPhase(jobPhasePlace)
|
||||||
if err := placeOutput(tmpOut, plan.out); err != nil {
|
if err := placeOutput(tmpOut, plan.out); err != nil {
|
||||||
_ = os.Remove(tmpOut)
|
_ = os.Remove(tmpOut)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
lPrintln("手动合并完成:" + plan.out)
|
lPrintln("手动合并完成" + tag + ":" + plan.out)
|
||||||
|
// 通知:手动合并没有 uid,能从文件名对上哪个主播就带上名字,对不上就用通用文案
|
||||||
|
uid := guessUIDByFileName(plan.out)
|
||||||
|
if uid == 0 {
|
||||||
|
uid = guessUIDByFileName(video)
|
||||||
|
}
|
||||||
|
text := "手动合并完成\n" + plan.out
|
||||||
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查合并参数,回显时长、弹幕条数和推测的偏移,不做实际合成
|
// 检查合并参数,回显时长、弹幕条数和推测的偏移,不做实际合成。
|
||||||
|
// 请求里带 jobs 时一次检查多个任务,返回 {jobs:[...]};单个任务仍直接返回 info
|
||||||
func mergeInfoHandler(w http.ResponseWriter, r *http.Request) {
|
func mergeInfoHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
var req mergeRequest
|
var req mergeRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
plan, err := buildMergePlan(req)
|
plans, err := buildMergePlans(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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)
|
lPrintErrf("输出合并检查结果失败:%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -520,7 +715,7 @@ func mergeHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
plan, err := buildMergePlan(req)
|
plans, err := buildMergePlans(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -533,32 +728,92 @@ func mergeHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "已经有一个手动合并在进行("+busyOut+"),等它结束再说", http.StatusConflict)
|
http.Error(w, "已经有一个手动合并在进行("+busyOut+"),等它结束再说", http.StatusConflict)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 任务卡片只有一份:自动合并也可能占着它,那时手动的也得等
|
||||||
|
if !tryTakeMergeSlot() {
|
||||||
|
mergeRun.Unlock()
|
||||||
|
busy := jobBusyName()
|
||||||
|
if busy == "" {
|
||||||
|
busy = "另一个合并任务"
|
||||||
|
}
|
||||||
|
http.Error(w, "已经有一个合并在进行("+busy+"),等它结束再说", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
mergeRun.busy = true
|
mergeRun.busy = true
|
||||||
mergeRun.outName = plan.info.OutName
|
mergeRun.outName = plans[0].info.OutName
|
||||||
mergeRun.lastErr = ""
|
mergeRun.lastErr = ""
|
||||||
|
mergeRun.total = len(plans)
|
||||||
|
mergeRun.done = 0
|
||||||
|
mergeRun.results = nil
|
||||||
mergeRun.Unlock()
|
mergeRun.Unlock()
|
||||||
|
|
||||||
|
total := len(plans)
|
||||||
safeGo("手动合并", func() {
|
safeGo("手动合并", func() {
|
||||||
|
defer releaseMergeSlot()
|
||||||
defer func() {
|
defer func() {
|
||||||
mergeRun.Lock()
|
mergeRun.Lock()
|
||||||
mergeRun.busy = false
|
mergeRun.busy = false
|
||||||
mergeRun.doneAt = time.Now().Unix()
|
mergeRun.doneAt = time.Now().Unix()
|
||||||
mergeRun.Unlock()
|
mergeRun.Unlock()
|
||||||
}()
|
}()
|
||||||
if err := runManualMerge(plan); err != nil {
|
stopped := false
|
||||||
|
for i, plan := range plans {
|
||||||
|
mergeRun.Lock()
|
||||||
|
mergeRun.outName = plan.info.OutName
|
||||||
|
mergeRun.done = i
|
||||||
|
mergeRun.Unlock()
|
||||||
|
|
||||||
|
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()
|
msg := err.Error()
|
||||||
if err == errNoDanmu {
|
switch err {
|
||||||
|
case errNoDanmu:
|
||||||
msg = "弹幕文件里没有弹幕"
|
msg = "弹幕文件里没有弹幕"
|
||||||
lPrintWarn("弹幕文件里没有弹幕,取消手动合并")
|
lPrintWarn("弹幕文件里没有弹幕,跳过这个任务")
|
||||||
} else {
|
case errJobStopped:
|
||||||
|
// 手动停止不是失败,别让界面弹「合并失败」;排在后面的任务也不再接着做
|
||||||
|
lPrintWarn("手动合并已被手动停止,剩下的任务不再继续")
|
||||||
|
stopped = true
|
||||||
|
default:
|
||||||
lPrintErrf("手动合并失败:%v", err)
|
lPrintErrf("手动合并失败:%v", err)
|
||||||
|
notifyMergeFailure(guessUIDByFileName(plan.out), plan.out, err)
|
||||||
}
|
}
|
||||||
mergeRun.Lock()
|
mergeRun.Lock()
|
||||||
|
mergeRun.results = append(mergeRun.results, mergeJobResult{OutName: plan.info.OutName, Err: msg})
|
||||||
mergeRun.lastErr = msg
|
mergeRun.lastErr = msg
|
||||||
mergeRun.Unlock()
|
mergeRun.Unlock()
|
||||||
|
if stopped {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mergeRun.Lock()
|
||||||
|
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.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) + `}`))
|
||||||
}
|
}
|
||||||
|
|||||||
+256
-6
@@ -63,6 +63,16 @@ Dialogue: 0,0:00:03.50,0:00:13.50,Danmu,丙(303),20,20,2,,{\move(400,24,-40,24)}
|
|||||||
|
|
||||||
// 测试用的 ffmpeg,找不到就跳过
|
// 测试用的 ffmpeg,找不到就跳过
|
||||||
// 依次找 PATH 和源码目录(以及上一级目录)里的 ffmpeg,不写死某台机器上的路径
|
// 依次找 PATH 和源码目录(以及上一级目录)里的 ffmpeg,不写死某台机器上的路径
|
||||||
|
// 按真实链路跑一次手动合并:独占任务位,跑完一定要收尾。
|
||||||
|
// 不能直接调 runManualMerge —— 上一个用例如果没收尾,jobStart 会拒掉这一场
|
||||||
|
// (正是它的职责),测试就会莫名其妙失败
|
||||||
|
func execManualMerge(plan mergePlan) error {
|
||||||
|
jobFinish("")
|
||||||
|
err := runMergeExclusive(func() error { return runManualMerge(plan, 1, 1) })
|
||||||
|
jobFinish(jobErrText(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func testFFmpeg(t *testing.T) string {
|
func testFFmpeg(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
cands := []string{"ffmpeg"}
|
cands := []string{"ffmpeg"}
|
||||||
@@ -153,7 +163,7 @@ func TestParseAssFilePlain(t *testing.T) {
|
|||||||
dir := setupMergeTest(t)
|
dir := setupMergeTest(t)
|
||||||
path := writeTestFile(t, filepath.Join(dir, "2026-09-25 01-00-00 测试主播 标题.ass"), plainAssFixture)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("解析失败:%v", err)
|
t.Fatalf("解析失败:%v", err)
|
||||||
}
|
}
|
||||||
@@ -193,7 +203,7 @@ func TestParseAssFileNick(t *testing.T) {
|
|||||||
dir := setupMergeTest(t)
|
dir := setupMergeTest(t)
|
||||||
path := writeTestFile(t, filepath.Join(dir, "2026-09-25 01-00-00 测试主播 标题.nick.ass"), nickAssFixture)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("解析失败:%v", err)
|
t.Fatalf("解析失败:%v", err)
|
||||||
}
|
}
|
||||||
@@ -228,7 +238,7 @@ func TestParseAssFileWithoutFormatLine(t *testing.T) {
|
|||||||
dir := setupMergeTest(t)
|
dir := setupMergeTest(t)
|
||||||
path := writeTestFile(t, filepath.Join(dir, "noformat.ass"), noFormatAssFixture)
|
path := writeTestFile(t, filepath.Join(dir, "noformat.ass"), noFormatAssFixture)
|
||||||
|
|
||||||
meta, records, err := parseAssFile(path)
|
meta, records, _, err := parseAssFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("解析失败:%v", err)
|
t.Fatalf("解析失败:%v", err)
|
||||||
}
|
}
|
||||||
@@ -257,7 +267,7 @@ func TestParseAssFileRejectsBadFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for name, content := range cases {
|
for name, content := range cases {
|
||||||
path := writeTestFile(t, filepath.Join(dir, name), content)
|
path := writeTestFile(t, filepath.Join(dir, name), content)
|
||||||
if _, _, err := parseAssFile(path); err == nil {
|
if _, _, _, err := parseAssFile(path); err == nil {
|
||||||
t.Fatalf("%s 应该报错", name)
|
t.Fatalf("%s 应该报错", name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -641,7 +651,7 @@ Dialogue: 0,0:00:05.00,0:00:15.00,Danmu,甲(101),20,20,2,,{\move(400,72,-40,72)}
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 真正合并
|
// 真正合并
|
||||||
if err := runManualMerge(plan); err != nil {
|
if err := execManualMerge(plan); err != nil {
|
||||||
t.Fatalf("手动合并失败:%v", err)
|
t.Fatalf("手动合并失败:%v", err)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(plan.out); err != nil {
|
if _, err := os.Stat(plan.out); err != nil {
|
||||||
@@ -708,7 +718,7 @@ Dialogue: 0,0:00:05.00,0:00:15.00,Danmu,乙(202),0,0,0,,{\move(400,48,-40,48)}
|
|||||||
if filepath.Base(plan.videos[0]) != filepath.Base(v1) {
|
if filepath.Base(plan.videos[0]) != filepath.Base(v1) {
|
||||||
t.Fatalf("分段顺序应纠正为第一段在前,实际 %q", filepath.Base(plan.videos[0]))
|
t.Fatalf("分段顺序应纠正为第一段在前,实际 %q", filepath.Base(plan.videos[0]))
|
||||||
}
|
}
|
||||||
if err := runManualMerge(plan); err != nil {
|
if err := execManualMerge(plan); err != nil {
|
||||||
t.Fatalf("多段手动合并失败:%v", err)
|
t.Fatalf("多段手动合并失败:%v", err)
|
||||||
}
|
}
|
||||||
d := probeDuration(ffmpeg, plan.out)
|
d := probeDuration(ffmpeg, plan.out)
|
||||||
@@ -718,3 +728,243 @@ Dialogue: 0,0:00:05.00,0:00:15.00,Danmu,乙(202),0,0,0,,{\move(400,48,-40,48)}
|
|||||||
}
|
}
|
||||||
t.Logf("多段成片 %s(%.2f 秒)", filepath.Base(plan.out), d)
|
t.Logf("多段成片 %s(%.2f 秒)", filepath.Base(plan.out), d)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 手动合并:按视频文件名自动找同目录下的同名弹幕文件
|
||||||
|
func TestFindDanmuForVideo(t *testing.T) {
|
||||||
|
savedConfig := config
|
||||||
|
oldRecordDir := recordDir
|
||||||
|
savedMerge := config.MergeOutputDir
|
||||||
|
defer func() {
|
||||||
|
config = savedConfig
|
||||||
|
recordDir = oldRecordDir
|
||||||
|
config.MergeOutputDir = savedMerge
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 每个用例一套独立的目录环境
|
||||||
|
prepare := func(t *testing.T) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
config.Directory = dir
|
||||||
|
config.RecordSubdir = defaultRecordSubdir
|
||||||
|
config.MergeSubdir = defaultMergeSubdir
|
||||||
|
config.MergeOutputDir = ""
|
||||||
|
if recordDir == nil {
|
||||||
|
recordDir = new(string)
|
||||||
|
}
|
||||||
|
*recordDir = dir
|
||||||
|
recDir := recordSaveDir()
|
||||||
|
if err := os.MkdirAll(recDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return dir, recDir
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("优先用 jsonl", func(t *testing.T) {
|
||||||
|
_, recDir := prepare(t)
|
||||||
|
writeTestFile(t, filepath.Join(recDir, "a.mp4"), "v")
|
||||||
|
writeTestFile(t, filepath.Join(recDir, "a.ass"), "ass")
|
||||||
|
writeTestFile(t, filepath.Join(recDir, "a.jsonl"), "jsonl")
|
||||||
|
// 录播目录里的只填文件名,方便以后改保存目录
|
||||||
|
if got := findDanmuForVideo("a.mp4"); got != filepath.Join(recDir, "a.jsonl") {
|
||||||
|
t.Errorf("应优先 .jsonl,实际 %s", got)
|
||||||
|
}
|
||||||
|
if got := mergeFillValue(filepath.Join(recDir, "a.jsonl")); got != "a.jsonl" {
|
||||||
|
t.Errorf("录播目录里的应只写文件名,实际 %s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("没有 jsonl 时用 ass", func(t *testing.T) {
|
||||||
|
_, recDir := prepare(t)
|
||||||
|
writeTestFile(t, filepath.Join(recDir, "b.mp4"), "v")
|
||||||
|
writeTestFile(t, filepath.Join(recDir, "b.ass"), "ass")
|
||||||
|
if got := findDanmuForVideo("b.mp4"); got != filepath.Join(recDir, "b.ass") {
|
||||||
|
t.Errorf("应回退到 .ass,实际 %s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("只有昵称头像版 ass 也能认", func(t *testing.T) {
|
||||||
|
_, recDir := prepare(t)
|
||||||
|
writeTestFile(t, filepath.Join(recDir, "c.mp4"), "v")
|
||||||
|
writeTestFile(t, filepath.Join(recDir, "c.nick.ass"), "ass")
|
||||||
|
if got := findDanmuForVideo("c.mp4"); got != filepath.Join(recDir, "c.nick.ass") {
|
||||||
|
t.Errorf("应认 .nick.ass,实际 %s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("视频在别处时用同目录的弹幕并填绝对路径", func(t *testing.T) {
|
||||||
|
_, _ = prepare(t)
|
||||||
|
other := t.TempDir()
|
||||||
|
video := filepath.Join(other, "d.mp4")
|
||||||
|
writeTestFile(t, video, "v")
|
||||||
|
writeTestFile(t, filepath.Join(other, "d.jsonl"), "jsonl")
|
||||||
|
got := findDanmuForVideo(video)
|
||||||
|
if got != filepath.Join(other, "d.jsonl") {
|
||||||
|
t.Errorf("应找到同目录的弹幕,实际 %s", got)
|
||||||
|
}
|
||||||
|
if fill := mergeFillValue(got); fill != got {
|
||||||
|
t.Errorf("不在录播/成片目录时应填绝对路径,实际 %s", fill)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("同目录没有就看录播目录", func(t *testing.T) {
|
||||||
|
_, recDir := prepare(t)
|
||||||
|
writeTestFile(t, filepath.Join(recDir, "e.jsonl"), "jsonl")
|
||||||
|
other := t.TempDir()
|
||||||
|
video := filepath.Join(other, "e.mp4")
|
||||||
|
writeTestFile(t, video, "v")
|
||||||
|
got := findDanmuForVideo(video)
|
||||||
|
if got != filepath.Join(recDir, "e.jsonl") {
|
||||||
|
t.Errorf("应回退到录播目录,实际 %s", got)
|
||||||
|
}
|
||||||
|
if fill := mergeFillValue(got); fill != "e.jsonl" {
|
||||||
|
t.Errorf("录播目录里的应只写文件名,实际 %s", fill)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("找不到就返回空", func(t *testing.T) {
|
||||||
|
_, recDir := prepare(t)
|
||||||
|
writeTestFile(t, filepath.Join(recDir, "f.mp4"), "v")
|
||||||
|
if got := findDanmuForVideo("f.mp4"); got != "" {
|
||||||
|
t.Errorf("没有同名弹幕时应返回空,实际 %s", got)
|
||||||
|
}
|
||||||
|
if got := findDanmuForVideo("不存在的视频.mp4"); got != "" {
|
||||||
|
t.Errorf("视频本身找不到时应返回空,实际 %s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("成片目录里的也写文件名", func(t *testing.T) {
|
||||||
|
dir, _ := prepare(t)
|
||||||
|
mergeDir := mergeSaveDir()
|
||||||
|
if err := os.MkdirAll(mergeDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeTestFile(t, filepath.Join(mergeDir, "g.jsonl"), "jsonl")
|
||||||
|
if fill := mergeFillValue(filepath.Join(dir, config.MergeSubdir, "g.jsonl")); fill != "g.jsonl" {
|
||||||
|
t.Errorf("成片目录里的应只写文件名,实际 %s", fill)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 一次排多个合并任务 ====================
|
||||||
|
|
||||||
|
// 造一组能过检查的「视频 + 弹幕」:视频随便写点字节,弹幕用真能解析的 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -261,6 +261,8 @@ func notifyEventTitle(event string) string {
|
|||||||
return "开始录制"
|
return "开始录制"
|
||||||
case notifyEventMergeDone:
|
case notifyEventMergeDone:
|
||||||
return "合并完成"
|
return "合并完成"
|
||||||
|
case notifyEventMergeFail:
|
||||||
|
return "合并失败"
|
||||||
}
|
}
|
||||||
return "AcFun 直播录制"
|
return "AcFun 直播录制"
|
||||||
}
|
}
|
||||||
|
|||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// avatarXExpr 产出的表达式就长这样:A-(A+T)*(t<±S>)/D
|
||||||
|
var avatarXExprRE = regexp.MustCompile(`^(\d+)-\((\d+)\+(\d+)\)\*\(t([+-]\d+\.\d+)\)/(\d+)$`)
|
||||||
|
|
||||||
|
// 按块内时间 t 求 avatarXExpr 的值。只认它自己产出的这一种形式;
|
||||||
|
// 表达式一改,这里的正则就会失配,用例会明确报出来
|
||||||
|
func evalAvatarXExpr(t *testing.T, expr string, tInChunk float64) float64 {
|
||||||
|
t.Helper()
|
||||||
|
m := avatarXExprRE.FindStringSubmatch(expr)
|
||||||
|
if m == nil {
|
||||||
|
t.Fatalf("头像 x 表达式的形式变了,这个用例要跟着改:%s", expr)
|
||||||
|
}
|
||||||
|
playResX, _ := strconv.Atoi(m[1])
|
||||||
|
base, _ := strconv.Atoi(m[2])
|
||||||
|
total, _ := strconv.Atoi(m[3])
|
||||||
|
shift, _ := strconv.ParseFloat(m[4], 64)
|
||||||
|
den, _ := strconv.ParseFloat(m[5], 64)
|
||||||
|
if base != playResX {
|
||||||
|
t.Fatalf("表达式里两个底数不一致:%s", expr)
|
||||||
|
}
|
||||||
|
if den != danmuShowSeconds {
|
||||||
|
t.Fatalf("表达式的分母 %g 和弹幕停留时长 %g 对不上:%s", den, danmuShowSeconds, expr)
|
||||||
|
}
|
||||||
|
// %+.3f 打出来的是带符号的偏移量,所以这里是 t + shift
|
||||||
|
return float64(playResX) - float64(playResX+total)*(tInChunk+shift)/den
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文字层(libass 的 \move)和头像层(overlay 的 x 表达式)必须是同一条运动轨迹。
|
||||||
|
// 两边一旦各算各的,被块边界截断的弹幕就会出现「文字已经走到左边、头像还在半路上」——
|
||||||
|
// 线上看到的就是「头像在中途偏离弹幕轨迹」
|
||||||
|
func TestAvatarAndTextShareSameMotion(t *testing.T) {
|
||||||
|
const playResX, avPad, total = 1920, 40, 600
|
||||||
|
for _, t0 := range []float64{0, 0.5, 123.456} {
|
||||||
|
// 块起点取三种典型位置:就是弹幕起播那一刻、在它之前 3 秒(被截断的续画)、
|
||||||
|
// 以及一个很大的时间轴位置(验证大数下公式也不跑偏)
|
||||||
|
for _, chunkStart := range []float64{t0, t0 - 3, t0 + 2000} {
|
||||||
|
expr := avatarXExpr(playResX, total, chunkStart-t0)
|
||||||
|
for _, tAbs := range []float64{t0, t0 + 0.01, t0 + 1.5, t0 + 9.999, t0 + danmuShowSeconds} {
|
||||||
|
wantText := danmuTextX(playResX, avPad, total, tAbs, t0)
|
||||||
|
gotAv := evalAvatarXExpr(t, expr, tAbs-chunkStart)
|
||||||
|
if math.Abs(gotAv-(wantText-float64(avPad))) > 0.5 {
|
||||||
|
t.Fatalf("t0=%.3f 块起点=%.3f t=%.3f 时文字左边缘 %.2f、头像 %.2f,"+
|
||||||
|
"两者应当相差一个头像宽度 %d", t0, chunkStart, tAbs, wantText, gotAv, avPad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 出发点和终点也要对:起播瞬间文字左边缘刚好在画面右侧外一个「头像+间隔」处
|
||||||
|
// (对应的头像左边缘正好贴着画面右边界),停留结束时整条刚好完全离开左边界 ——
|
||||||
|
// 起点是 playResX 出头的那点偏移,终点是 -total,行程正好 playResX+total
|
||||||
|
if got := danmuTextX(playResX, avPad, total, 0, 0); got != float64(playResX+avPad) {
|
||||||
|
t.Errorf("起播瞬间文字左边缘应当是 %d,实际 %.2f", playResX+avPad, got)
|
||||||
|
}
|
||||||
|
if got := danmuAvatarX(playResX, avPad, total, 0, 0); got != float64(playResX) {
|
||||||
|
t.Errorf("起播瞬间头像左边缘应当贴着画面右边界 %d,实际 %.2f", playResX, got)
|
||||||
|
}
|
||||||
|
if got := danmuTextX(playResX, avPad, total, danmuShowSeconds, 0); got != float64(avPad-total) {
|
||||||
|
t.Errorf("停留结束时文字左边缘应当是 %d,实际 %.2f", avPad-total, got)
|
||||||
|
}
|
||||||
|
if got := danmuAvatarX(playResX, avPad, total, danmuShowSeconds, 0); got != float64(-total) {
|
||||||
|
t.Errorf("停留结束时头像左边缘(整条的左端)应当是 %d,实际 %.2f", -total, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写进 ASS 的 \move 起止点必须按**绝对时间**算,而不是「截断之后的这一小段」:
|
||||||
|
// 拿截断后的时长当整段行程(旧实现:终点写死 -Width)等于把 10 秒的路挤进零点几秒,
|
||||||
|
// 文字会飞过去;分块渲染里 66.4% 的弹幕都被截断过,所以这个错法影响面极大
|
||||||
|
func TestWriteChunkAssKeepsAbsoluteTrajectory(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
meta := danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 42}
|
||||||
|
avW, avGap := avatarSize(meta.FontSize), avatarGap(meta.FontSize)
|
||||||
|
total := avW + avGap + 480
|
||||||
|
|
||||||
|
// 块从 100 秒开始,弹幕起播在 96 秒 —— 块头把它切掉了,只剩最后 6 秒可见
|
||||||
|
chunk := danmuChunk{start: 100, end: 130, records: []danmuRecord{
|
||||||
|
{Time: 96, UID: 7, Name: "甲", Content: "被块头截断的", Lane: 2, Width: 480},
|
||||||
|
}}
|
||||||
|
path := filepath.Join(dir, "chunk.ass")
|
||||||
|
if err := writeChunkAss(path, meta, chunk, 0, avW, avGap); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := regexp.MustCompile(`\\move\((-?\d+),(-?\d+),(-?\d+),(-?\d+)\)`).FindStringSubmatch(string(data))
|
||||||
|
if m == nil {
|
||||||
|
t.Fatalf("ASS 里找不到 \\move:\n%s", data)
|
||||||
|
}
|
||||||
|
nums := make([]int, 4)
|
||||||
|
for i := range nums {
|
||||||
|
nums[i], _ = strconv.Atoi(m[i+1])
|
||||||
|
}
|
||||||
|
|
||||||
|
t0 := 96.0
|
||||||
|
wantX1 := int(math.Round(danmuTextX(meta.PlayResX, avW+avGap, total, chunk.start, t0)))
|
||||||
|
wantX2 := int(math.Round(danmuTextX(meta.PlayResX, avW+avGap, total, t0+danmuShowSeconds, t0)))
|
||||||
|
if nums[0] != wantX1 || nums[2] != wantX2 {
|
||||||
|
t.Errorf("\\move 起止点应当是 (%d → %d),实际 (%d → %d);"+
|
||||||
|
"终点按截断后的时长算会把 10 秒的路挤进 %.1f 秒里,文字直接飞过去",
|
||||||
|
wantX1, wantX2, nums[0], nums[2], chunk.end-t0)
|
||||||
|
}
|
||||||
|
// 终点必须是「走完全程、整条离开左边界」的位置,不是文字宽度本身
|
||||||
|
if nums[2] != avW+avGap-total {
|
||||||
|
t.Errorf("停留结束时的终点应当是 %d(= avPad - total),实际 %d", avW+avGap-total, nums[2])
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "{") && !strings.Contains(string(data), `\an7`) {
|
||||||
|
t.Error("对话行丢了 \\an7 定位锚点")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,11 +18,12 @@ const (
|
|||||||
notifyEventLiveOff = "liveOff"
|
notifyEventLiveOff = "liveOff"
|
||||||
notifyEventRecordStart = "recordStart"
|
notifyEventRecordStart = "recordStart"
|
||||||
notifyEventMergeDone = "mergeDone"
|
notifyEventMergeDone = "mergeDone"
|
||||||
|
notifyEventMergeFail = "mergeFail"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 推送到 webhook 的请求体
|
// 推送到 webhook 的请求体
|
||||||
type notifyPayload struct {
|
type notifyPayload struct {
|
||||||
Event string `json:"event"` // 事件名:liveOn、liveOff、recordStart、mergeDone
|
Event string `json:"event"` // 事件名:liveOn、liveOff、recordStart、mergeDone、mergeFail
|
||||||
UID int `json:"uid"` // 主播 uid
|
UID int `json:"uid"` // 主播 uid
|
||||||
Name string `json:"name"` // 主播名字
|
Name string `json:"name"` // 主播名字
|
||||||
Title string `json:"title"` // 直播标题(下播和合并完成时可能为空)
|
Title string `json:"title"` // 直播标题(下播和合并完成时可能为空)
|
||||||
@@ -81,6 +82,7 @@ type notifySetting struct {
|
|||||||
liveOff bool
|
liveOff bool
|
||||||
recordStart bool
|
recordStart bool
|
||||||
mergeDone bool
|
mergeDone bool
|
||||||
|
mergeFail bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func currentNotifySetting() notifySetting {
|
func currentNotifySetting() notifySetting {
|
||||||
@@ -100,6 +102,7 @@ func currentNotifySetting() notifySetting {
|
|||||||
liveOff: config.NotifyLiveOff,
|
liveOff: config.NotifyLiveOff,
|
||||||
recordStart: config.NotifyRecordStart,
|
recordStart: config.NotifyRecordStart,
|
||||||
mergeDone: config.NotifyMergeDone,
|
mergeDone: config.NotifyMergeDone,
|
||||||
|
mergeFail: config.NotifyMergeFail,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +117,8 @@ func (n notifySetting) enabled(event string) bool {
|
|||||||
return n.recordStart
|
return n.recordStart
|
||||||
case notifyEventMergeDone:
|
case notifyEventMergeDone:
|
||||||
return n.mergeDone
|
return n.mergeDone
|
||||||
|
case notifyEventMergeFail:
|
||||||
|
return n.mergeFail
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -201,6 +206,22 @@ func sendNotify(event string, uid int, name, title, url, file, text string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 合并失败时推送。nil、「弹幕文件里没有弹幕」和被手动停止都不算失败,不会推送。
|
||||||
|
// fileName 用于正文里标出是哪一场,拿不到就留空
|
||||||
|
func notifyMergeFailure(uid int, fileName string, err error) {
|
||||||
|
if err == nil || err == errNoDanmu || err == errJobStopped {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text := "合并失败"
|
||||||
|
if uid > 0 {
|
||||||
|
text += "(" + longID(uid) + " 的录播和弹幕)"
|
||||||
|
} else if fileName != "" {
|
||||||
|
text += "(" + fileName + ")"
|
||||||
|
}
|
||||||
|
text += "\n原因:" + err.Error()
|
||||||
|
sendNotify(notifyEventMergeFail, uid, nameForLog(uid), "", "", fileName, text)
|
||||||
|
}
|
||||||
|
|
||||||
// 把事件 POST 到 webhook,同时返回 HTTP 状态码(失败时也会带上,方便测试接口显示细节)
|
// 把事件 POST 到 webhook,同时返回 HTTP 状态码(失败时也会带上,方便测试接口显示细节)
|
||||||
func postNotify(addr, token string, p notifyPayload) (int, error) {
|
func postNotify(addr, token string, p notifyPayload) (int, error) {
|
||||||
data, err := json.Marshal(p)
|
data, err := json.Marshal(p)
|
||||||
|
|||||||
+175
@@ -2,10 +2,12 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -437,6 +439,7 @@ func TestNotifyEventTitle(t *testing.T) {
|
|||||||
notifyEventLiveOff: "下播提醒",
|
notifyEventLiveOff: "下播提醒",
|
||||||
notifyEventRecordStart: "开始录制",
|
notifyEventRecordStart: "开始录制",
|
||||||
notifyEventMergeDone: "合并完成",
|
notifyEventMergeDone: "合并完成",
|
||||||
|
notifyEventMergeFail: "合并失败",
|
||||||
} {
|
} {
|
||||||
if got := notifyEventTitle(event); got != want {
|
if got := notifyEventTitle(event); got != want {
|
||||||
t.Errorf("notifyEventTitle(%q) = %q,期望 %q", event, got, want)
|
t.Errorf("notifyEventTitle(%q) = %q,期望 %q", event, got, want)
|
||||||
@@ -444,6 +447,77 @@ func TestNotifyEventTitle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 合并失败要推送,而「正常结束 / 文件里没有弹幕 / 用户自己点的停止」都不许推:
|
||||||
|
// 前两种本来就不算失败,第三种推「合并失败」会误导
|
||||||
|
func TestNotifyMergeFailureFanOut(t *testing.T) {
|
||||||
|
savedConfig := config
|
||||||
|
resetMeowBadNick()
|
||||||
|
defer func() {
|
||||||
|
config = savedConfig
|
||||||
|
resetMeowBadNick()
|
||||||
|
}()
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var hooks []notifyPayload
|
||||||
|
webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var p notifyPayload
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&p)
|
||||||
|
mu.Lock()
|
||||||
|
hooks = append(hooks, p)
|
||||||
|
mu.Unlock()
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
}))
|
||||||
|
defer webhook.Close()
|
||||||
|
|
||||||
|
countHooks := func() int {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
return len(hooks)
|
||||||
|
}
|
||||||
|
waitHooks := func(want int) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for time.Now().Before(deadline) && countHooks() < want {
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.NotifyEnabled = true
|
||||||
|
config.NotifyURL = webhook.URL
|
||||||
|
config.NotifyMergeFail = true
|
||||||
|
config.NotifyMergeDone = false
|
||||||
|
|
||||||
|
notifyMergeFailure(280178, "2026-09-25 01-00-00 甲_弹幕合并.mp4", errors.New("磁盘写满了"))
|
||||||
|
waitHooks(1)
|
||||||
|
mu.Lock()
|
||||||
|
got := append([]notifyPayload{}, hooks...)
|
||||||
|
mu.Unlock()
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("合并失败应当推送一条,实际 %d 条", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Event != notifyEventMergeFail {
|
||||||
|
t.Errorf("事件名应当是 %s,实际 %s", notifyEventMergeFail, got[0].Event)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got[0].Text, "磁盘写满了") {
|
||||||
|
t.Errorf("正文里应当带上失败原因,实际:%s", got[0].Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyMergeFailure(280178, "x.mp4", nil)
|
||||||
|
notifyMergeFailure(280178, "x.mp4", errNoDanmu)
|
||||||
|
notifyMergeFailure(280178, "x.mp4", errJobStopped)
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
if n := countHooks(); n != 1 {
|
||||||
|
t.Errorf("正常结束 / 没有弹幕 / 手动停止都不该推送,实际共 %d 条", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
config.NotifyMergeFail = false
|
||||||
|
notifyMergeFailure(280178, "x.mp4", errors.New("又失败了"))
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
if n := countHooks(); n != 1 {
|
||||||
|
t.Errorf("没勾「合并失败」时不该推送,实际共 %d 条", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 两个渠道的启用条件必须互相独立,并且都受各自开关控制
|
// 两个渠道的启用条件必须互相独立,并且都受各自开关控制
|
||||||
func TestNotifySettingChannelReady(t *testing.T) {
|
func TestNotifySettingChannelReady(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
@@ -592,3 +666,104 @@ func TestNotifyTestHandler(t *testing.T) {
|
|||||||
t.Errorf("非法昵称应该 400,实际 %d", code)
|
t.Errorf("非法昵称应该 400,实际 %d", code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 手动合并完成也要推送 —— 以前只有自动合并会推,所以在页面上手动合并时 MeoW 一直没动静
|
||||||
|
func TestManualMergeSendsMergeDoneNotify(t *testing.T) {
|
||||||
|
setupMergeTest(t)
|
||||||
|
ffmpeg := getFFmpeg()
|
||||||
|
if ffmpeg == "" {
|
||||||
|
t.Skip("没有 ffmpeg")
|
||||||
|
}
|
||||||
|
|
||||||
|
savedConfig := config
|
||||||
|
defer func() { config = savedConfig }()
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var hooks []notifyPayload
|
||||||
|
webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var p notifyPayload
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&p)
|
||||||
|
mu.Lock()
|
||||||
|
hooks = append(hooks, p)
|
||||||
|
mu.Unlock()
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
}))
|
||||||
|
defer webhook.Close()
|
||||||
|
|
||||||
|
var meowQuery []string
|
||||||
|
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
meowQuery = append(meowQuery, r.URL.RawQuery)
|
||||||
|
mu.Unlock()
|
||||||
|
_, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 只开合并完成这一个事件,两个渠道都开
|
||||||
|
config.NotifyURL = webhook.URL
|
||||||
|
config.NotifyLiveOn = false
|
||||||
|
config.NotifyMergeDone = true
|
||||||
|
config.MeowEnabled = true
|
||||||
|
config.MeowNickname = "小小怪"
|
||||||
|
config.MeowMsgType = "text"
|
||||||
|
config.UIDs = uidList{{UID: 999, Danmu: true, Record: true}}
|
||||||
|
// 文件名里带主播名,手动合并靠它认人
|
||||||
|
streamers.Lock()
|
||||||
|
streamers.crt = map[int]streamer{999: {UID: 999, Name: "测试主播"}}
|
||||||
|
streamers.Unlock()
|
||||||
|
|
||||||
|
name := "2026-09-25 01-00-00 测试主播 标题"
|
||||||
|
video := filepath.Join(recordSaveDir(), name+".mp4")
|
||||||
|
if out, err := exec.Command(ffmpeg, "-v", "error", "-y",
|
||||||
|
"-f", "lavfi", "-i", "color=c=black:size=320x180:rate=15", "-t", "3",
|
||||||
|
"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", video).CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("生成测试视频失败:%v\n%s", err, out)
|
||||||
|
}
|
||||||
|
lines := []string{`{"playResX":320,"playResY":180,"fontSize":16,"startTime":0}`,
|
||||||
|
`{"t":0.5,"uid":101,"name":"甲","content":"手动合并推送测试"}`,
|
||||||
|
`{"t":1.5,"uid":102,"name":"乙","content":"第二条"}`}
|
||||||
|
writeTestFile(t, filepath.Join(recordSaveDir(), name+".jsonl"), strings.Join(lines, "\n")+"\n")
|
||||||
|
|
||||||
|
plan, err := buildMergePlan(mergeRequest{Videos: []string{name + ".mp4"}, Danmu: name + ".jsonl"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("检查失败:%v", err)
|
||||||
|
}
|
||||||
|
if err := runManualMerge(plan, 1, 1); err != nil {
|
||||||
|
t.Fatalf("手动合并失败:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
mu.Lock()
|
||||||
|
nh, nm := len(hooks), len(meowQuery)
|
||||||
|
mu.Unlock()
|
||||||
|
if nh > 0 && nm > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if len(hooks) != 1 {
|
||||||
|
t.Fatalf("QQ 机器人应收到 1 条推送,实际 %d 条", len(hooks))
|
||||||
|
}
|
||||||
|
p := hooks[0]
|
||||||
|
if p.Event != notifyEventMergeDone {
|
||||||
|
t.Errorf("事件名应为 %s,实际 %s", notifyEventMergeDone, p.Event)
|
||||||
|
}
|
||||||
|
if p.UID != 999 || p.Name != "测试主播" {
|
||||||
|
t.Errorf("应从文件名认出主播 999/测试主播,实际 %d/%q", p.UID, p.Name)
|
||||||
|
}
|
||||||
|
if p.File != plan.out {
|
||||||
|
t.Errorf("推送里的文件应为成片 %q,实际 %q", plan.out, p.File)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Text, "手动合并") || !strings.Contains(p.Text, plan.out) {
|
||||||
|
t.Errorf("推送文案不对:%q", p.Text)
|
||||||
|
}
|
||||||
|
if len(meowQuery) != 1 {
|
||||||
|
t.Fatalf("MeoW 应收到 1 条推送,实际 %d 条", len(meowQuery))
|
||||||
|
}
|
||||||
|
if !strings.Contains(meowQuery[0], "msgType=text") {
|
||||||
|
t.Errorf("MeoW 的 msgType 应走 query 参数,实际 %q", meowQuery[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,10 +104,27 @@ func TestCleanTempFiles(t *testing.T) {
|
|||||||
writeTestFile(t, filepath.Join(recordSaveDir(), "2026-09-25 01-00-00 甲.mp4"), "视频"),
|
writeTestFile(t, filepath.Join(recordSaveDir(), "2026-09-25 01-00-00 甲.mp4"), "视频"),
|
||||||
writeTestFile(t, filepath.Join(mergeSaveDir(), "2026-09-25 01-00-00 甲_弹幕合并.mp4"), "成片"),
|
writeTestFile(t, filepath.Join(mergeSaveDir(), "2026-09-25 01-00-00 甲_弹幕合并.mp4"), "成片"),
|
||||||
}
|
}
|
||||||
|
// 预缩放头像是个目录,也要一起清掉;别顺手把普通的录播子目录当成临时文件夹删了
|
||||||
|
avatarDir := filepath.Join(recordSaveDir(), chunkAvatarDir)
|
||||||
|
if err := os.MkdirAll(avatarDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeTestFile(t, filepath.Join(avatarDir, "1.png"), "头像")
|
||||||
|
normalDir := filepath.Join(recordSaveDir(), "别的文件夹")
|
||||||
|
if err := os.MkdirAll(normalDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
cleanTempFiles(recordSaveDir())
|
cleanTempFiles(recordSaveDir())
|
||||||
cleanTempFiles(mergeSaveDir())
|
cleanTempFiles(mergeSaveDir())
|
||||||
|
|
||||||
|
if _, err := os.Stat(avatarDir); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("残留的预缩放头像文件夹应该被整个清掉:%s", chunkAvatarDir)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(normalDir); err != nil {
|
||||||
|
t.Errorf("名字不带临时前缀的目录不该被删:%s", filepath.Base(normalDir))
|
||||||
|
}
|
||||||
|
|
||||||
for _, p := range append(append([]string{}, staleRecord...), staleMerge) {
|
for _, p := range append(append([]string{}, staleRecord...), staleMerge) {
|
||||||
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||||
t.Errorf("残留的临时文件应该被清掉:%s", filepath.Base(p))
|
t.Errorf("残留的临时文件应该被清掉:%s", filepath.Base(p))
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/orzogc/acfundanmu"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 直接摆布全局任务状态:测试里只碰这几个标志位,用完恢复
|
||||||
|
func withJobFlags(t *testing.T, active, paused, stopped bool) {
|
||||||
|
t.Helper()
|
||||||
|
job.Lock()
|
||||||
|
prevActive, prevPaused, prevStopped, prevProc := job.active, job.paused, job.stopped, job.proc
|
||||||
|
job.active, job.paused, job.stopped = active, paused, stopped
|
||||||
|
job.Unlock()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
job.Lock()
|
||||||
|
job.active, job.paused, job.stopped, job.proc = prevActive, prevPaused, prevStopped, prevProc
|
||||||
|
job.Unlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// resX + length 为 0(PlayResX 配置异常 + 空内容)时不能整数除零
|
||||||
|
func TestScrollMarksFallsBackWhenTotalIsZero(t *testing.T) {
|
||||||
|
emerge, left := scrollMarks(1000, 0, 0, danmuShowDur)
|
||||||
|
if emerge != 1000+danmuShowDur/2 {
|
||||||
|
t.Fatalf("行程为 0 时 emerge 应当退到半个停留时长,实际 %d", emerge-1000)
|
||||||
|
}
|
||||||
|
if left != 1000+danmuShowDur {
|
||||||
|
t.Fatalf("行程为 0 时 left 应当退到一个停留时长,实际 %d", left-1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 端到端:配置里 PlayResX 为 0 又收到一条空弹幕时,写入器不能 panic
|
||||||
|
func TestDanmuWriterSurvivesZeroPlayRes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := acfundanmu.SubConfig{Title: "测试", PlayResX: 0, PlayResY: 0,
|
||||||
|
FontSize: 40, StartTime: 1700000000000000000}
|
||||||
|
w := newDanmuWriter("liveid", "stream", cfg, filepath.Join(dir, "zero.ass"))
|
||||||
|
|
||||||
|
c := &acfundanmu.Comment{Content: ""} // 空内容 → 宽度 0,和 PlayResX=0 一起把分母变成 0
|
||||||
|
c.SendTime = (cfg.StartTime + int64(time.Second)) / 1e6
|
||||||
|
c.UserID = 1
|
||||||
|
c.Nickname = "甲"
|
||||||
|
w.write(c) // 原来这里整数除零 panic,会终止整场录制
|
||||||
|
if err := w.close(); err != nil {
|
||||||
|
t.Fatalf("关文件不该报错:%v", err)
|
||||||
|
}
|
||||||
|
if w.failed() {
|
||||||
|
t.Fatalf("写盘不该出错:%v", w.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 这条弹幕仍要正常出现在 jsonl 里
|
||||||
|
b, err := os.ReadFile(filepath.Join(dir, "zero.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if lines := strings.Split(strings.TrimSpace(string(b)), "\n"); len(lines) != 2 {
|
||||||
|
t.Fatalf("应当写出 1 条弹幕记录(加首行元数据共 2 行),实际 %d 行", len(lines))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 任务已请求停止时,jobAttach 要就地结束刚启动的进程,
|
||||||
|
// 否则用户点了停止还得等整个块渲完
|
||||||
|
func TestJobAttachKillsProcessWhenStopped(t *testing.T) {
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
cmd = exec.Command("cmd", "/c", "ping", "-n", "30", "127.0.0.1")
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("sleep", "30")
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
t.Skipf("起不了测试子进程,跳过:%v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = cmd.Process.Kill() }()
|
||||||
|
|
||||||
|
withJobFlags(t, true, false, true) // 任务已经处于「已请求停止」
|
||||||
|
jobAttach(cmd)
|
||||||
|
defer jobDetach(cmd)
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- cmd.Wait() }()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("任务已请求停止时,jobAttach 应当就地结束刚启动的进程,不能等它自己跑完")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 挂起失败(进程已经不在)时不能留着「已暂停」的状态
|
||||||
|
func TestJobPauseRollsBackStateWhenSuspendFails(t *testing.T) {
|
||||||
|
withJobFlags(t, true, false, false)
|
||||||
|
fake := &exec.Cmd{Process: &os.Process{Pid: 999999}} // 不存在的进程号,挂起必然失败
|
||||||
|
jobAttach(fake)
|
||||||
|
defer jobDetach(fake)
|
||||||
|
|
||||||
|
if err := jobPause(); err == nil {
|
||||||
|
t.Fatal("挂起一个不存在的进程应当报错")
|
||||||
|
}
|
||||||
|
job.Lock()
|
||||||
|
paused := job.paused
|
||||||
|
job.Unlock()
|
||||||
|
if paused {
|
||||||
|
t.Fatal("挂起失败后不该留着「已暂停」:界面会显示已暂停,而 ffmpeg 其实还在跑")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有标记成临时的 jsonl 才能删,正常同名 jsonl 一个字节都不能动
|
||||||
|
func TestDropTempDanmuOnlyRemovesTempFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
keep := filepath.Join(dir, "keep.jsonl")
|
||||||
|
if err := os.WriteFile(keep, []byte("{}\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dropTempDanmu(danmuSource{jsonlPath: keep})
|
||||||
|
if _, err := os.Stat(keep); err != nil {
|
||||||
|
t.Fatalf("正常的同名 jsonl 不能被删掉:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp := filepath.Join(dir, "tmp.jsonl")
|
||||||
|
if err := os.WriteFile(tmp, []byte("{}\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dropTempDanmu(danmuSource{jsonlPath: tmp, tempJsonl: true})
|
||||||
|
if _, err := os.Stat(tmp); err == nil {
|
||||||
|
t.Fatal("标记为临时的还原结果应当在收尾时删掉")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 还原结果写不进原目录时退回系统临时目录,并且要打上临时标记
|
||||||
|
func TestPrepareDanmuSourceMarksTempFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
assPath := filepath.Join(dir, "bom.ass")
|
||||||
|
if err := os.WriteFile(assPath, []byte(plainAssFixture), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 同名 jsonl 占成只读:写不进去,只能退到临时目录
|
||||||
|
jsonlPath := filepath.Join(dir, "bom.jsonl")
|
||||||
|
if err := os.WriteFile(jsonlPath, []byte("{}\n"), 0444); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.Chmod(jsonlPath, 0644) })
|
||||||
|
|
||||||
|
src, err := prepareDanmuSource(assPath, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("这台机器上没能造出「写不进去」的条件,跳过:%v", err)
|
||||||
|
}
|
||||||
|
if !src.tempJsonl {
|
||||||
|
t.Skip("写进原目录成功了(文件系统不认只读属性),跳过")
|
||||||
|
}
|
||||||
|
if src.jsonlPath == jsonlPath {
|
||||||
|
t.Fatal("退回临时文件时 jsonlPath 不该还指着原路径")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(src.jsonlPath); err != nil {
|
||||||
|
t.Fatalf("临时文件应当已经写好:%v", err)
|
||||||
|
}
|
||||||
|
dropTempDanmu(src)
|
||||||
|
if _, err := os.Stat(src.jsonlPath); err == nil {
|
||||||
|
t.Fatal("临时文件应当在收尾时被删掉")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 带 UTF-8 BOM 的 ASS 也要能整份解析出来
|
||||||
|
func TestParseAssFileAcceptsBOM(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "bom.ass")
|
||||||
|
if err := os.WriteFile(p, []byte("\ufeff"+plainAssFixture), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, records, _, err := parseAssFile(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("带 BOM 的 ASS 应当能正常解析,实际报错:%v", err)
|
||||||
|
}
|
||||||
|
if meta.PlayResX != 1920 || meta.PlayResY != 1080 {
|
||||||
|
t.Fatalf("分辨率应当解析出来,实际 %dx%d", meta.PlayResX, meta.PlayResY)
|
||||||
|
}
|
||||||
|
if meta.FontSize != 48 {
|
||||||
|
t.Fatalf("字号应当解析出来,实际 %d", meta.FontSize)
|
||||||
|
}
|
||||||
|
if len(records) != 2 {
|
||||||
|
t.Fatalf("应当读到 2 条弹幕(Comment 那行不算),实际 %d", len(records))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止后下载头像不该把三次重试耗完(原来最坏要等 96 秒才响应停止)
|
||||||
|
func TestEnsureAvatarGivesUpAtOnceWhenStopped(t *testing.T) {
|
||||||
|
withJobFlags(t, true, false, true)
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
t0 := time.Now()
|
||||||
|
got := ensureAvatar(424242, "http://127.0.0.1:1/none.png", dir)
|
||||||
|
elapsed := time.Since(t0)
|
||||||
|
|
||||||
|
if got != "" {
|
||||||
|
t.Fatalf("停止状态下不该返回头像路径,实际 %q", got)
|
||||||
|
}
|
||||||
|
if elapsed > 3*time.Second {
|
||||||
|
t.Fatalf("停止后应当立刻放弃这个头像,实际等了 %v", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
+667
@@ -0,0 +1,667 @@
|
|||||||
|
// 转换任务(合并 / 烧录弹幕)的实时进度与暂停、停止控制。
|
||||||
|
// 进度只保留一份全局状态:同一时间只允许一个合并任务在跑,串行靠下面的 mergeSlot 保证
|
||||||
|
// (mergeRun.busy 只拦得住手动合并之间,拦不住自动合并,也拦不住手动与自动互相穿插)。
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 任务类型。阶段划分按它来取(见 jobPhasePlanFor)
|
||||||
|
const (
|
||||||
|
jobKindManualMerge = "手动合并"
|
||||||
|
jobKindAutoMerge = "自动合并"
|
||||||
|
jobKindTranscode = "转码"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 任务阶段,前端按这个顺序画步骤条
|
||||||
|
var jobPhaseNames = []string{"准备", "分段拼接", "获取头像", "画面渲染", "拼接与落盘"}
|
||||||
|
|
||||||
|
// 阶段序号,和 jobPhaseNames 一一对应
|
||||||
|
const (
|
||||||
|
jobPhasePrepare = iota // 准备(读弹幕、探测视频、生成字幕)
|
||||||
|
jobPhaseJoin // 分段拼接(断流分成多段时才有事做)
|
||||||
|
jobPhaseAvatar // 获取头像
|
||||||
|
jobPhaseRender // 画面渲染(最耗时)
|
||||||
|
jobPhasePlace // 拼接分块与落盘
|
||||||
|
)
|
||||||
|
|
||||||
|
// 各阶段在总进度里占的权重(加起来是 1)。渲染最耗时,权重给得最多;
|
||||||
|
// 其余阶段按经验估:拼接分段与头像下载是分钟级,准备和落盘是秒级。
|
||||||
|
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
|
||||||
|
jobMaxSamples = 120
|
||||||
|
)
|
||||||
|
|
||||||
|
// 任务被手动停止时各层返回这个错误:调用方据此清理临时文件,不再回退其它编码器
|
||||||
|
var errJobStopped = errors.New("任务已被手动停止")
|
||||||
|
|
||||||
|
// 起任务时任务位被别人占着:这次没有真的开始,调用方据此跳过 jobFinish,
|
||||||
|
// 不然会把正在跑的那个任务的进度卡片清掉
|
||||||
|
var errJobNotStarted = errors.New("已经有别的任务在跑,请先等它结束")
|
||||||
|
|
||||||
|
// 速度折线上的一个点
|
||||||
|
type jobSample struct {
|
||||||
|
T float64 `json:"t"` // 任务开始后的秒数
|
||||||
|
Speed float64 `json:"speed"` // 当时的渲染倍速
|
||||||
|
}
|
||||||
|
|
||||||
|
// 给前端的任务快照
|
||||||
|
type jobInfo struct {
|
||||||
|
Active bool `json:"active"`
|
||||||
|
Kind string `json:"kind"` // 手动合并 / 自动合并
|
||||||
|
OutName string `json:"outName"` // 成片文件名
|
||||||
|
PhaseIdx int `json:"phaseIdx"` // 当前阶段序号,-1 表示已全部完成
|
||||||
|
Phases []string `json:"phases"` // 阶段名列表
|
||||||
|
PhasePercent float64 `json:"phasePercent"`
|
||||||
|
Percent float64 `json:"percent"` // 总进度 0~100
|
||||||
|
Paused bool `json:"paused"`
|
||||||
|
Stopping bool `json:"stopping"`
|
||||||
|
Speed float64 `json:"speed"` // 渲染倍速,例如 1.37
|
||||||
|
FPS float64 `json:"fps"`
|
||||||
|
DoneSec float64 `json:"doneSec"` // 已渲染时长
|
||||||
|
TotalSec float64 `json:"totalSec"` // 视频总时长
|
||||||
|
ChunkDone int `json:"chunkDone"`
|
||||||
|
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
|
||||||
|
OutBytes int64 `json:"outBytes"` // 已经写出多少字节
|
||||||
|
ETASec float64 `json:"etaSec"` // 预计剩余秒数,0 表示算不出来
|
||||||
|
ElapsedSec float64 `json:"elapsedSec"` // 已用时间
|
||||||
|
Samples []jobSample `json:"samples"` // 速度折线
|
||||||
|
Message string `json:"message"` // 结束后的提示(完成 / 失败原因)
|
||||||
|
Err string `json:"err"` // 失败原因,成功为空
|
||||||
|
DoneAt int64 `json:"doneAt"` // 结束时刻(Unix 秒)
|
||||||
|
Pausable bool `json:"pausable"` // 现在是否能暂停
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局唯一的任务状态
|
||||||
|
type jobState struct {
|
||||||
|
sync.RWMutex
|
||||||
|
active bool
|
||||||
|
kind string
|
||||||
|
outName string
|
||||||
|
phaseIdx int
|
||||||
|
phasePct float64
|
||||||
|
started time.Time
|
||||||
|
paused bool
|
||||||
|
stopped bool
|
||||||
|
proc *exec.Cmd // 当前在跑的 ffmpeg,暂停/停止都作用在它身上
|
||||||
|
|
||||||
|
// 当前任务的阶段划分,随任务类型在 jobStart 时定下来
|
||||||
|
phases []string
|
||||||
|
weights []float64
|
||||||
|
renderIdx int
|
||||||
|
// 批量任务(手动合并队列、转码队列)的进度,单任务时都是 0
|
||||||
|
queueDone int
|
||||||
|
queueTotal int
|
||||||
|
|
||||||
|
totalSec float64
|
||||||
|
doneSec float64
|
||||||
|
speed float64
|
||||||
|
fps float64
|
||||||
|
|
||||||
|
chunkDone int
|
||||||
|
chunkTotal int
|
||||||
|
avatarDone int
|
||||||
|
avatarTotal int
|
||||||
|
|
||||||
|
inputBytes int64
|
||||||
|
// 按目标码率算出来的预计成片大小
|
||||||
|
planOutBytes int64
|
||||||
|
// 按「已写出字节 ÷ 已渲染比例」外推出来的预计成片大小,渲染到 5% 后才有值
|
||||||
|
liveOutBytes int64
|
||||||
|
outBytes int64
|
||||||
|
|
||||||
|
samples []jobSample
|
||||||
|
lastSample time.Time
|
||||||
|
|
||||||
|
errText string
|
||||||
|
message string
|
||||||
|
doneAt int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var job jobState
|
||||||
|
|
||||||
|
// 合并任务的串行锁。全局 job 状态只有一份,两个任务同时跑的话后启动的会把前一个的
|
||||||
|
// 进度和输出名清掉,「停止」也会杀掉别家任务的 ffmpeg,所以必须排队。
|
||||||
|
// 手动合并用 TryLock(抢不到立刻告诉用户),自动合并用 Lock(后台任务,排队等就行)。
|
||||||
|
var mergeSlot sync.Mutex
|
||||||
|
|
||||||
|
// 尝试占用合并任务位,被占着返回 false。给手动合并用:不能让 HTTP 请求干等着
|
||||||
|
func tryTakeMergeSlot() bool {
|
||||||
|
return mergeSlot.TryLock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 占用合并任务位,被占着就等到前一个跑完。给自动合并用
|
||||||
|
func takeMergeSlot() {
|
||||||
|
mergeSlot.Lock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func releaseMergeSlot() {
|
||||||
|
mergeSlot.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排队跑完一整个合并任务:先独占任务位,跑完后一定释放,被调用方 panic 也不漏。
|
||||||
|
// 内部出错会被翻成 error 而不是让上层去 recover —— 任务卡片只有走到 jobFinish 才会收尾,
|
||||||
|
// 漏掉的话进度会永远停在界面上(之前 panic 后卡片一直转就是这么来的)
|
||||||
|
func runMergeExclusive(f func() error) (err error) {
|
||||||
|
takeMergeSlot()
|
||||||
|
defer releaseMergeSlot()
|
||||||
|
defer func() {
|
||||||
|
if p := recover(); p != nil {
|
||||||
|
err = fmt.Errorf("合并任务内部出错:%v", p)
|
||||||
|
lPrintErrf("合并任务内部出错,已兜住:%v", p)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return f()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始一个新任务。kind 是任务类型,totalSec 是视频总时长(拿不到填 0)。
|
||||||
|
// 已经有任务在跑时返回 false 并且什么都不动:调用方应该先拿到 mergeSlot,
|
||||||
|
// 这里再做一次判断是为了让误用(忘记排队)只会失败而不会互相覆盖
|
||||||
|
func jobStart(kind, outName string, inputBytes int64, totalSec float64) bool {
|
||||||
|
job.Lock()
|
||||||
|
defer job.Unlock()
|
||||||
|
if job.active {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
job.paused = false
|
||||||
|
job.stopped = false
|
||||||
|
job.proc = nil
|
||||||
|
job.totalSec = totalSec
|
||||||
|
job.doneSec = 0
|
||||||
|
job.speed = 0
|
||||||
|
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
|
||||||
|
job.outBytes = 0
|
||||||
|
job.samples = nil
|
||||||
|
job.lastSample = time.Time{}
|
||||||
|
job.errText = ""
|
||||||
|
job.message = ""
|
||||||
|
job.doneAt = 0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前是否有任务在跑。给界面提示和「现在能不能接新任务」的判断用
|
||||||
|
func jobActive() bool {
|
||||||
|
job.RLock()
|
||||||
|
defer job.RUnlock()
|
||||||
|
return job.active
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前任务的名字(没任务时为空),拒绝新任务时用它告诉用户在忙什么
|
||||||
|
func jobBusyName() string {
|
||||||
|
job.RLock()
|
||||||
|
defer job.RUnlock()
|
||||||
|
if !job.active {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return job.outName
|
||||||
|
}
|
||||||
|
|
||||||
|
// 任务结束。errText 为空表示成功,errJobStopped.Error() 表示被手动停止
|
||||||
|
func jobFinish(errText string) {
|
||||||
|
job.Lock()
|
||||||
|
defer job.Unlock()
|
||||||
|
job.active = false
|
||||||
|
job.paused = false
|
||||||
|
job.stopped = false
|
||||||
|
job.proc = nil
|
||||||
|
job.speed = 0
|
||||||
|
job.errText = errText
|
||||||
|
job.doneAt = time.Now().Unix()
|
||||||
|
switch errText {
|
||||||
|
case "":
|
||||||
|
job.phaseIdx = len(job.phases)
|
||||||
|
job.phasePct = 1
|
||||||
|
job.message = "已完成"
|
||||||
|
case errJobStopped.Error():
|
||||||
|
job.message = "已停止"
|
||||||
|
default:
|
||||||
|
job.message = "失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否已经被要求停止
|
||||||
|
func jobStopRequested() bool {
|
||||||
|
job.Lock()
|
||||||
|
defer job.Unlock()
|
||||||
|
return job.stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
// 各阶段在进入下一步前调用这个「闸门」:暂停时在这里等,被停止时立刻返回错误。
|
||||||
|
// 放在循环体里,这样下载头像、逐块渲染这些阶段都能被暂停和停止。
|
||||||
|
func jobGate() error {
|
||||||
|
for {
|
||||||
|
job.Lock()
|
||||||
|
stopped, paused := job.stopped, job.paused
|
||||||
|
job.Unlock()
|
||||||
|
if stopped {
|
||||||
|
return errJobStopped
|
||||||
|
}
|
||||||
|
if !paused {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
time.Sleep(120 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换阶段。序号只往前走,重复设置同一阶段不会把进度打回 0
|
||||||
|
func jobSetPhase(idx int) {
|
||||||
|
job.Lock()
|
||||||
|
defer job.Unlock()
|
||||||
|
if !job.active {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if idx > job.phaseIdx {
|
||||||
|
job.phaseIdx = idx
|
||||||
|
job.phasePct = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接设置当前阶段的完成比例(0~1),只取更大值,避免折返
|
||||||
|
func jobSetPhasePercent(p float64) {
|
||||||
|
job.Lock()
|
||||||
|
defer job.Unlock()
|
||||||
|
if !job.active {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p > job.phasePct {
|
||||||
|
job.phasePct = clamp01(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染进度:doneSec 是累计已渲染的秒数(分块时已折算成整片时间轴),
|
||||||
|
// cumBytes 是累计已写出的字节数(分块时是「已完成块 + 当前块」)
|
||||||
|
func jobRender(doneSec, speed, fps float64, cumBytes int64) {
|
||||||
|
job.Lock()
|
||||||
|
defer job.Unlock()
|
||||||
|
if !job.active {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if doneSec > job.doneSec {
|
||||||
|
job.doneSec = doneSec
|
||||||
|
}
|
||||||
|
if speed > 0 {
|
||||||
|
job.speed = speed
|
||||||
|
}
|
||||||
|
if fps > 0 {
|
||||||
|
job.fps = fps
|
||||||
|
}
|
||||||
|
if cumBytes > job.outBytes {
|
||||||
|
job.outBytes = cumBytes
|
||||||
|
}
|
||||||
|
ratio := 0.0
|
||||||
|
if job.totalSec > 0 {
|
||||||
|
ratio = clamp01(job.doneSec / job.totalSec)
|
||||||
|
}
|
||||||
|
if ratio > job.phasePct {
|
||||||
|
job.phasePct = ratio
|
||||||
|
}
|
||||||
|
// 渲染到 5% 之后,用「已写出 ÷ 已完成比例」外推整片体积,比按码率估的准
|
||||||
|
if ratio >= 0.05 && job.outBytes > 0 {
|
||||||
|
if est := int64(float64(job.outBytes) / ratio); est > 0 {
|
||||||
|
job.liveOutBytes = est
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 速度折线:最多 1 秒一个点
|
||||||
|
now := time.Now()
|
||||||
|
if job.lastSample.IsZero() || now.Sub(job.lastSample) >= jobSampleInterval {
|
||||||
|
job.lastSample = now
|
||||||
|
job.samples = append(job.samples, jobSample{T: now.Sub(job.started).Seconds(), Speed: speed})
|
||||||
|
if len(job.samples) > jobMaxSamples {
|
||||||
|
job.samples = append([]jobSample(nil), job.samples[len(job.samples)-jobMaxSamples:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已分块渲染的块数,给界面显示「第 3/12 块」
|
||||||
|
func jobChunks(done, total int) {
|
||||||
|
job.Lock()
|
||||||
|
defer job.Unlock()
|
||||||
|
if !job.active {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
job.chunkDone, job.chunkTotal = done, total
|
||||||
|
}
|
||||||
|
|
||||||
|
// 头像下载进度(按用户数)
|
||||||
|
func jobAvatars(done, total int) {
|
||||||
|
job.Lock()
|
||||||
|
defer job.Unlock()
|
||||||
|
if !job.active {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
job.avatarDone, job.avatarTotal = done, total
|
||||||
|
if total > 0 {
|
||||||
|
p := float64(done) / float64(total)
|
||||||
|
if p > job.phasePct {
|
||||||
|
job.phasePct = clamp01(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量任务(手动合并队列、转码队列)排到第几个。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()
|
||||||
|
defer job.Unlock()
|
||||||
|
if !job.active {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
job.totalSec = sec
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按目标码率算出来的预计成片大小(byte)
|
||||||
|
func jobPlanSize(bytes int64) {
|
||||||
|
job.Lock()
|
||||||
|
defer job.Unlock()
|
||||||
|
if !job.active {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
job.planOutBytes = bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把当前 ffmpeg 进程注册进来,暂停/停止才作用得到。
|
||||||
|
// 如果注册时任务已经处于暂停状态,直接把它挂起(在启动前点了暂停的情况);
|
||||||
|
// 如果任务已经被要求停止,就地杀掉 —— jobGate 已经过去了,不补这一刀的话
|
||||||
|
// 用户点了停止还得等整个块渲完(大块的渲染可能要几分钟)
|
||||||
|
func jobAttach(cmd *exec.Cmd) {
|
||||||
|
job.Lock()
|
||||||
|
job.proc = cmd
|
||||||
|
paused, stopped := job.paused, job.stopped
|
||||||
|
job.Unlock()
|
||||||
|
if cmd == nil || cmd.Process == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if stopped {
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if paused {
|
||||||
|
_ = suspendProcess(cmd.Process)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jobDetach(cmd *exec.Cmd) {
|
||||||
|
job.Lock()
|
||||||
|
if job.proc == cmd {
|
||||||
|
job.proc = nil
|
||||||
|
}
|
||||||
|
job.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暂停:挂起当前 ffmpeg 进程;不在 ffmpeg 阶段时靠 jobGate 在下一步生效
|
||||||
|
func jobPause() error {
|
||||||
|
job.Lock()
|
||||||
|
if !job.active {
|
||||||
|
job.Unlock()
|
||||||
|
return errors.New("当前没有正在运行的合并任务")
|
||||||
|
}
|
||||||
|
if job.paused {
|
||||||
|
job.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
job.paused = true
|
||||||
|
cmd := job.proc
|
||||||
|
job.Unlock()
|
||||||
|
if cmd != nil && cmd.Process != nil {
|
||||||
|
if err := suspendProcess(cmd.Process); err != nil {
|
||||||
|
// 没挂住就别留着「已暂停」:界面会显示暂停了、ffmpeg 其实还在跑,
|
||||||
|
// 用户以为停住了却在烧 CPU。只在进程还是同一个时回滚,免得踩到已经换过的进程
|
||||||
|
job.Lock()
|
||||||
|
if job.proc == cmd {
|
||||||
|
job.paused = false
|
||||||
|
}
|
||||||
|
job.Unlock()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复
|
||||||
|
func jobResume() error {
|
||||||
|
job.Lock()
|
||||||
|
if !job.active {
|
||||||
|
job.Unlock()
|
||||||
|
return errors.New("当前没有正在运行的合并任务")
|
||||||
|
}
|
||||||
|
if !job.paused {
|
||||||
|
job.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
job.paused = false
|
||||||
|
cmd := job.proc
|
||||||
|
job.Unlock()
|
||||||
|
if cmd != nil && cmd.Process != nil {
|
||||||
|
if err := resumeProcess(cmd.Process); err != nil {
|
||||||
|
// 没恢复成就把状态放回「已暂停」:否则界面显示在跑、ffmpeg 其实还停着
|
||||||
|
job.Lock()
|
||||||
|
if job.proc == cmd {
|
||||||
|
job.paused = true
|
||||||
|
}
|
||||||
|
job.Unlock()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止:杀掉当前 ffmpeg,并置位让各层在下一个闸门返回 errJobStopped
|
||||||
|
func jobStop() error {
|
||||||
|
job.Lock()
|
||||||
|
if !job.active {
|
||||||
|
job.Unlock()
|
||||||
|
return errors.New("当前没有正在运行的合并任务")
|
||||||
|
}
|
||||||
|
job.stopped = true
|
||||||
|
job.paused = false
|
||||||
|
cmd := job.proc
|
||||||
|
job.Unlock()
|
||||||
|
if cmd != nil && cmd.Process != nil {
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp01(v float64) float64 {
|
||||||
|
if v < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if v > 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// 总进度:已走完的阶段按权重全算,当前阶段按阶段内比例折算
|
||||||
|
func (j *jobState) percentLocked() float64 {
|
||||||
|
weights := j.weights
|
||||||
|
if len(weights) == 0 {
|
||||||
|
weights = jobPhaseWeights
|
||||||
|
}
|
||||||
|
p := 0.0
|
||||||
|
for i, w := range weights {
|
||||||
|
switch {
|
||||||
|
case i < j.phaseIdx:
|
||||||
|
p += w
|
||||||
|
case i == j.phaseIdx:
|
||||||
|
p += w * clamp01(j.phasePct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return math.Min(p*100, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预计剩余时间:渲染阶段用「剩余时长 ÷ 倍速」,其它阶段按已用时间线性外推
|
||||||
|
func (j *jobState) etaLocked(now time.Time) float64 {
|
||||||
|
if !j.active {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if j.phaseIdx == j.renderIdx && j.speed > 0 && j.totalSec > j.doneSec {
|
||||||
|
return (j.totalSec - j.doneSec) / j.speed
|
||||||
|
}
|
||||||
|
pct := j.percentLocked()
|
||||||
|
if pct <= 0.5 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return now.Sub(j.started).Seconds() * (100 - pct) / pct
|
||||||
|
}
|
||||||
|
|
||||||
|
// 给页面的快照
|
||||||
|
func jobSnapshot() jobInfo {
|
||||||
|
job.Lock()
|
||||||
|
defer job.Unlock()
|
||||||
|
|
||||||
|
info := jobInfo{
|
||||||
|
Active: job.active,
|
||||||
|
Kind: job.kind,
|
||||||
|
OutName: job.outName,
|
||||||
|
PhaseIdx: job.phaseIdx,
|
||||||
|
Phases: job.phases,
|
||||||
|
Paused: job.paused,
|
||||||
|
Stopping: job.stopped,
|
||||||
|
Speed: job.speed,
|
||||||
|
FPS: job.fps,
|
||||||
|
DoneSec: job.doneSec,
|
||||||
|
TotalSec: job.totalSec,
|
||||||
|
ChunkDone: job.chunkDone,
|
||||||
|
ChunkTotal: job.chunkTotal,
|
||||||
|
AvatarDone: job.avatarDone,
|
||||||
|
AvatarTotal: job.avatarTotal,
|
||||||
|
QueueDone: job.queueDone,
|
||||||
|
QueueTotal: job.queueTotal,
|
||||||
|
InputBytes: job.inputBytes,
|
||||||
|
OutBytes: job.outBytes,
|
||||||
|
Message: job.message,
|
||||||
|
Err: job.errText,
|
||||||
|
DoneAt: job.doneAt,
|
||||||
|
}
|
||||||
|
info.PhasePercent = clamp01(job.phasePct) * 100
|
||||||
|
info.Percent = job.percentLocked()
|
||||||
|
// 预计成片大小:有实测外推就用实测,否则用按码率估的
|
||||||
|
info.EstOutBytes, info.EstSource = job.planOutBytes, "bitrate"
|
||||||
|
if job.liveOutBytes > 0 {
|
||||||
|
info.EstOutBytes, info.EstSource = job.liveOutBytes, "measured"
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
info.ETASec = job.etaLocked(now)
|
||||||
|
if job.active {
|
||||||
|
info.ElapsedSec = now.Sub(job.started).Seconds()
|
||||||
|
} else if job.doneAt > 0 && !job.started.IsZero() {
|
||||||
|
info.ElapsedSec = time.Unix(job.doneAt, 0).Sub(job.started).Seconds()
|
||||||
|
}
|
||||||
|
if len(job.samples) > 0 {
|
||||||
|
info.Samples = append([]jobSample(nil), job.samples...)
|
||||||
|
}
|
||||||
|
// 暂停靠 jobGate 生效:ffmpeg 在跑时直接挂起进程,不在跑时在下一个闸门处停住
|
||||||
|
info.Pausable = job.active
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/job:当前转换任务的快照,页面轮询它来画进度
|
||||||
|
func jobHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(jobSnapshot()); err != nil {
|
||||||
|
lPrintErrf("输出任务进度失败:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/job/control:暂停 / 恢复 / 停止当前任务
|
||||||
|
func jobControlHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
switch req.Action {
|
||||||
|
case "pause":
|
||||||
|
err = jobPause()
|
||||||
|
case "resume":
|
||||||
|
err = jobResume()
|
||||||
|
case "stop":
|
||||||
|
err = jobStop()
|
||||||
|
default:
|
||||||
|
http.Error(w, "不认识的操作:"+req.Action, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 测试用的「开一个新任务」。全局任务只有一份,用例之间必须清干净:
|
||||||
|
// 上一个没收尾的话 jobStart 会拒绝覆盖(这是刻意的防串台),断言就会读到别人的数据
|
||||||
|
func startJobForTest(t *testing.T, kind, outName string, inputBytes int64, totalSec float64) {
|
||||||
|
t.Helper()
|
||||||
|
jobFinish("")
|
||||||
|
if !jobStart(kind, outName, inputBytes, totalSec) {
|
||||||
|
t.Fatalf("jobStart 失败:还残留着未结束的任务 %q", jobBusyName())
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { jobFinish("") })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阶段权重加起来必须正好是 1,否则总进度永远到不了 100%
|
||||||
|
func TestJobPhaseWeightsSumToOne(t *testing.T) {
|
||||||
|
if len(jobPhaseNames) != len(jobPhaseWeights) {
|
||||||
|
t.Fatalf("阶段名有 %d 个,权重有 %d 个,对不上", len(jobPhaseNames), len(jobPhaseWeights))
|
||||||
|
}
|
||||||
|
sum := 0.0
|
||||||
|
for _, w := range jobPhaseWeights {
|
||||||
|
sum += w
|
||||||
|
}
|
||||||
|
if sum < 0.999999 || sum > 1.000001 {
|
||||||
|
t.Errorf("阶段权重之和应为 1,实际 %v", sum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 总进度:走过的阶段按权重满算,当前阶段按比例折算
|
||||||
|
func TestJobPercentByPhase(t *testing.T) {
|
||||||
|
startJobForTest(t, "测试", "x.mp4", 0, 100)
|
||||||
|
|
||||||
|
if got := jobSnapshot().Percent; got != 0 {
|
||||||
|
t.Errorf("刚开始时总进度应为 0,实际 %v", got)
|
||||||
|
}
|
||||||
|
// 走到渲染阶段但一帧没渲染:前面三个阶段(准备 / 分段拼接 / 头像)已满算
|
||||||
|
jobSetPhase(jobPhaseRender)
|
||||||
|
want := (jobPhaseWeights[0] + jobPhaseWeights[1] + jobPhaseWeights[2]) * 100
|
||||||
|
if got := jobSnapshot().Percent; got < want-0.001 || got > want+0.001 {
|
||||||
|
t.Errorf("进入渲染阶段时总进度应为 %v,实际 %v", want, got)
|
||||||
|
}
|
||||||
|
// 渲染一半
|
||||||
|
jobRender(50, 1.5, 25, 0)
|
||||||
|
want += jobPhaseWeights[jobPhaseRender] * 50
|
||||||
|
if got := jobSnapshot().Percent; got < want-0.001 || got > want+0.001 {
|
||||||
|
t.Errorf("渲染一半时总进度应为 %v,实际 %v", want, got)
|
||||||
|
}
|
||||||
|
// 结束(成功)后必须正好 100%
|
||||||
|
jobFinish("")
|
||||||
|
if got := jobSnapshot().Percent; got != 100 {
|
||||||
|
t.Errorf("完成后总进度应为 100,实际 %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阶段序号只往前走,重复设置同一阶段不会把进度打回 0
|
||||||
|
func TestJobPhaseOnlyMovesForward(t *testing.T) {
|
||||||
|
startJobForTest(t, "测试", "x.mp4", 0, 100)
|
||||||
|
defer jobFinish("")
|
||||||
|
jobSetPhase(jobPhaseRender)
|
||||||
|
jobRender(60, 1, 25, 0)
|
||||||
|
before := jobSnapshot().Percent
|
||||||
|
jobSetPhase(jobPhaseAvatar) // 往回设,应该被忽略
|
||||||
|
if got := jobSnapshot().Percent; got < before {
|
||||||
|
t.Errorf("阶段倒退把进度打回去了:%v -> %v", before, got)
|
||||||
|
}
|
||||||
|
if idx := jobSnapshot().PhaseIdx; idx != jobPhaseRender {
|
||||||
|
t.Errorf("阶段应为 %d,实际 %d", jobPhaseRender, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染阶段的 ETA = 剩余时长 ÷ 倍速
|
||||||
|
func TestJobEtaFromSpeed(t *testing.T) {
|
||||||
|
startJobForTest(t, "测试", "x.mp4", 0, 200)
|
||||||
|
defer jobFinish("")
|
||||||
|
jobSetPhase(jobPhaseRender)
|
||||||
|
jobRender(50, 2.0, 25, 0)
|
||||||
|
// 还剩 150 秒,2 倍速 → 75 秒
|
||||||
|
if got := jobSnapshot().ETASec; got < 74.5 || got > 75.5 {
|
||||||
|
t.Errorf("ETA 应为约 75 秒,实际 %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预计输出大小:先给按码率估的值,渲染过 5% 之后换成按已写出字节外推
|
||||||
|
func TestJobEstOutSize(t *testing.T) {
|
||||||
|
startJobForTest(t, "测试", "x.mp4", 0, 100)
|
||||||
|
defer jobFinish("")
|
||||||
|
jobSetPhase(jobPhaseRender)
|
||||||
|
|
||||||
|
jobPlanSize(5000)
|
||||||
|
info := jobSnapshot()
|
||||||
|
if info.EstOutBytes != 5000 || info.EstSource != "bitrate" {
|
||||||
|
t.Fatalf("还没开始渲染时应按码率估 5000,实际 %d(%s)", info.EstOutBytes, info.EstSource)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染到 50%,写出 4000 字节 → 外推整片 8000
|
||||||
|
jobRender(50, 1, 25, 4000)
|
||||||
|
info = jobSnapshot()
|
||||||
|
if info.EstSource != "measured" {
|
||||||
|
t.Fatalf("渲染过 5%% 后应改用实测外推,实际来源 %s", info.EstSource)
|
||||||
|
}
|
||||||
|
if info.EstOutBytes != 8000 {
|
||||||
|
t.Errorf("外推应为 8000,实际 %d", info.EstOutBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 比例太小的时候不急着外推,免得开头抖动
|
||||||
|
startJobForTest(t, "测试", "x.mp4", 0, 100)
|
||||||
|
jobSetPhase(jobPhaseRender)
|
||||||
|
jobPlanSize(5000)
|
||||||
|
jobRender(1, 1, 25, 100)
|
||||||
|
if got := jobSnapshot(); got.EstSource != "bitrate" {
|
||||||
|
t.Errorf("渲染不到 5%% 时不该改用外推,实际来源 %s", got.EstSource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 速度折线:最多保留固定点数,且按秒采样
|
||||||
|
func TestJobSamples(t *testing.T) {
|
||||||
|
startJobForTest(t, "测试", "x.mp4", 0, 100)
|
||||||
|
defer jobFinish("")
|
||||||
|
jobSetPhase(jobPhaseRender)
|
||||||
|
// 同一秒内连着报多次只留一个点
|
||||||
|
jobRender(1, 1.0, 25, 0)
|
||||||
|
jobRender(2, 1.1, 25, 0)
|
||||||
|
jobRender(3, 1.2, 25, 0)
|
||||||
|
if n := len(jobSnapshot().Samples); n != 1 {
|
||||||
|
t.Errorf("同一秒内的多次上报应只留 1 个采样点,实际 %d", n)
|
||||||
|
}
|
||||||
|
// 攒够 1 秒之后再报就会多一个点
|
||||||
|
job.Lock()
|
||||||
|
job.lastSample = time.Now().Add(-2 * time.Second)
|
||||||
|
job.Unlock()
|
||||||
|
jobRender(4, 1.3, 25, 0)
|
||||||
|
samples := jobSnapshot().Samples
|
||||||
|
if len(samples) != 2 {
|
||||||
|
t.Fatalf("应有两个采样点,实际 %d", len(samples))
|
||||||
|
}
|
||||||
|
if samples[1].Speed != 1.3 {
|
||||||
|
t.Errorf("第二个采样点的速度应为 1.3,实际 %v", samples[1].Speed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暂停 / 恢复 / 停止的状态流转;停止后闸门必须让上层拿到 errJobStopped
|
||||||
|
func TestJobPauseStopGate(t *testing.T) {
|
||||||
|
startJobForTest(t, "测试", "x.mp4", 0, 100)
|
||||||
|
defer jobFinish("")
|
||||||
|
|
||||||
|
if err := jobPause(); err != nil {
|
||||||
|
t.Fatalf("暂停失败:%v", err)
|
||||||
|
}
|
||||||
|
if !jobSnapshot().Paused {
|
||||||
|
t.Fatal("暂停后状态里应为 paused")
|
||||||
|
}
|
||||||
|
// 暂停期间闸门会一直等,放到 goroutine 里,再恢复让它出来
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- jobGate() }()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
t.Fatalf("暂停期间闸门不该放行,却返回了 %v", err)
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
}
|
||||||
|
if err := jobResume(); err != nil {
|
||||||
|
t.Fatalf("恢复失败:%v", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("恢复后闸门应放行,实际返回 %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("恢复后闸门没有放行")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := jobStop(); err != nil {
|
||||||
|
t.Fatalf("停止失败:%v", err)
|
||||||
|
}
|
||||||
|
if err := jobGate(); !errors.Is(err, errJobStopped) {
|
||||||
|
t.Errorf("停止后闸门应返回 errJobStopped,实际 %v", err)
|
||||||
|
}
|
||||||
|
// 停止不是「失败」,界面要能区分开
|
||||||
|
jobFinish(errJobStopped.Error())
|
||||||
|
info := jobSnapshot()
|
||||||
|
if info.Active {
|
||||||
|
t.Error("结束后不该还是 active")
|
||||||
|
}
|
||||||
|
if info.Message != "已停止" || info.Err != errJobStopped.Error() {
|
||||||
|
t.Errorf("停止后的提示应为「已停止」,实际 message=%q err=%q", info.Message, info.Err)
|
||||||
|
}
|
||||||
|
if jobErrText(nil) != "" || jobErrText(errNoDanmu) != "" {
|
||||||
|
t.Error("正常结束和「没有弹幕」都不该算失败")
|
||||||
|
}
|
||||||
|
if jobErrText(errJobStopped) != errJobStopped.Error() {
|
||||||
|
t.Error("停止的原因要原样传给界面")
|
||||||
|
}
|
||||||
|
if jobErrText(errors.New("炸了")) != "炸了" {
|
||||||
|
t.Error("其它错误要原样传给界面")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有任务时控制接口要明确报错,而不是静默成功
|
||||||
|
func TestJobControlWithoutTask(t *testing.T) {
|
||||||
|
jobFinish("")
|
||||||
|
if err := jobPause(); err == nil {
|
||||||
|
t.Error("没有任务时暂停应该报错")
|
||||||
|
}
|
||||||
|
if err := jobPause(); err == nil {
|
||||||
|
t.Error("没有任务时恢复应该报错")
|
||||||
|
}
|
||||||
|
if err := jobStop(); err == nil {
|
||||||
|
t.Error("没有任务时停止应该报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ffmpeg 的 speed 字段形如 "1.37x",暂停或还没开始时是 "N/A"
|
||||||
|
func TestParseFFmpegSpeed(t *testing.T) {
|
||||||
|
cases := map[string]float64{
|
||||||
|
"1.37x": 1.37,
|
||||||
|
"2x": 2,
|
||||||
|
"N/A": 0,
|
||||||
|
"": 0,
|
||||||
|
"-1x": 0,
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := parseFFmpegSpeed(in); got != want {
|
||||||
|
t.Errorf("parseFFmpegSpeed(%q) 应为 %v,实际 %v", in, want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 -progress pipe:1 的输出,只在 progress= 行回调一次
|
||||||
|
func TestScanFFmpegProgress(t *testing.T) {
|
||||||
|
// out_time_us 和 out_time_ms 都是微秒(ffmpeg 的历史遗留)
|
||||||
|
text := strings.Join([]string{
|
||||||
|
"frame=100",
|
||||||
|
"fps=25.0",
|
||||||
|
"total_size=1048576",
|
||||||
|
"out_time_us=4000000",
|
||||||
|
"out_time_ms=4000000",
|
||||||
|
"out_time=00:00:04.000000",
|
||||||
|
"speed=1.37x",
|
||||||
|
"progress=continue",
|
||||||
|
"frame=200",
|
||||||
|
"out_time_us=8000000",
|
||||||
|
"total_size=2097152",
|
||||||
|
"speed=N/A",
|
||||||
|
"progress=end",
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
type call struct {
|
||||||
|
sec, speed, fps float64
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
var got []call
|
||||||
|
scanFFmpegProgress(strings.NewReader(text), func(sec, speed, fps float64, size int64) {
|
||||||
|
got = append(got, call{sec, speed, fps, size})
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("应该回调 2 次,实际 %d 次:%+v", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0].sec != 4 || got[0].speed != 1.37 || got[0].fps != 25 || got[0].size != 1048576 {
|
||||||
|
t.Errorf("第一次回调不对:%+v", got[0])
|
||||||
|
}
|
||||||
|
if got[1].sec != 8 || got[1].size != 2097152 {
|
||||||
|
t.Errorf("第二次回调不对:%+v", got[1])
|
||||||
|
}
|
||||||
|
// 暂停时 speed 会是 N/A,这时不该把上一次的倍速顶掉
|
||||||
|
if got[1].speed != 0 {
|
||||||
|
t.Errorf("N/A 应该解析成 0,实际 %v", got[1].speed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 混着无关行和坏数据也不能崩
|
||||||
|
func TestScanFFmpegProgressTolerant(t *testing.T) {
|
||||||
|
text := "乱七八糟\nspeed=abc\nprogress=continue\nout_time_us=不是数字\nprogress=end\n"
|
||||||
|
n := 0
|
||||||
|
scanFFmpegProgress(strings.NewReader(text), func(sec, speed, fps float64, size int64) {
|
||||||
|
n++
|
||||||
|
if sec != 0 || speed != 0 {
|
||||||
|
t.Errorf("坏数据不该解析出值:sec=%v speed=%v", sec, speed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if n != 2 {
|
||||||
|
t.Errorf("progress= 行都应回调,期望 2 次,实际 %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 截图式地看一眼任务快照的字段都填上了
|
||||||
|
func TestJobSnapshotFields(t *testing.T) {
|
||||||
|
startJobForTest(t, "手动合并", "成片.mp4", 123456, 3600)
|
||||||
|
defer jobFinish("")
|
||||||
|
jobSetPhase(jobPhaseAvatar)
|
||||||
|
jobAvatars(3, 10)
|
||||||
|
jobSetTotal(3600)
|
||||||
|
info := jobSnapshot()
|
||||||
|
if !info.Active || info.Kind != "手动合并" || info.OutName != "成片.mp4" {
|
||||||
|
t.Fatalf("基本信息不对:%+v", info)
|
||||||
|
}
|
||||||
|
if info.InputBytes != 123456 || info.TotalSec != 3600 {
|
||||||
|
t.Errorf("输入大小 / 总时长不对:%+v", info)
|
||||||
|
}
|
||||||
|
if info.AvatarDone != 3 || info.AvatarTotal != 10 {
|
||||||
|
t.Errorf("头像进度不对:%+v", info)
|
||||||
|
}
|
||||||
|
if len(info.Phases) != len(jobPhaseNames) {
|
||||||
|
t.Errorf("阶段列表长度应为 %d,实际 %d", len(jobPhaseNames), len(info.Phases))
|
||||||
|
}
|
||||||
|
if info.PhasePercent < 29.9 || info.PhasePercent > 30.1 {
|
||||||
|
t.Errorf("头像阶段进度应为 30%%,实际 %v", info.PhasePercent)
|
||||||
|
}
|
||||||
|
if !info.Pausable {
|
||||||
|
t.Error("任务在跑的时候应该可暂停")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 任务串行化 ====================
|
||||||
|
// 全局任务状态只有一份:两个合并同时跑的话,后启动的会把前一个的进度和输出名清掉,
|
||||||
|
// 点「停止」还会杀掉别家任务的 ffmpeg。下面几条例外情况锁住改串行之后的语义。
|
||||||
|
|
||||||
|
// jobStart 在已经有任务在跑时必须拒绝,并且一个字段都不能动
|
||||||
|
func TestJobStartRejectsWhenBusy(t *testing.T) {
|
||||||
|
startJobForTest(t, "自动合并", "甲_弹幕合并.mp4", 1000, 100)
|
||||||
|
jobSetPhase(jobPhaseRender)
|
||||||
|
jobRender(30, 1.2, 25, 5000)
|
||||||
|
|
||||||
|
if ok := jobStart("手动合并", "乙_弹幕合并.mp4", 2000, 200); ok {
|
||||||
|
t.Fatal("已经有任务在跑,jobStart 应当返回 false")
|
||||||
|
}
|
||||||
|
info := jobSnapshot()
|
||||||
|
if info.Kind != "自动合并" || info.OutName != "甲_弹幕合并.mp4" {
|
||||||
|
t.Errorf("被拒之后不该动别人的任务信息:%+v", info)
|
||||||
|
}
|
||||||
|
if info.InputBytes != 1000 || info.TotalSec != 100 {
|
||||||
|
t.Errorf("被拒之后输入大小和总时长也不该变:%+v", info)
|
||||||
|
}
|
||||||
|
if info.Percent < 25 || info.Percent > 45 {
|
||||||
|
t.Errorf("被拒之后进度必须还是原任务自己的(25~45),实际 %v", info.Percent)
|
||||||
|
}
|
||||||
|
if !info.Active {
|
||||||
|
t.Error("拒绝新任务不代表把老任务结束掉")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 任务位被占着时手动合并要立刻知道(不能让页面干等),抢不到只能有一个赢
|
||||||
|
func TestMergeSlotTryLockExclusive(t *testing.T) {
|
||||||
|
if !tryTakeMergeSlot() {
|
||||||
|
t.Fatal("空闲时手动合并应该抢得到任务位")
|
||||||
|
}
|
||||||
|
if tryTakeMergeSlot() {
|
||||||
|
t.Fatal("任务位已经被占着,第二次 TryLock 不该成功")
|
||||||
|
}
|
||||||
|
releaseMergeSlot()
|
||||||
|
if !tryTakeMergeSlot() {
|
||||||
|
t.Fatal("释放之后应该又能抢到")
|
||||||
|
}
|
||||||
|
releaseMergeSlot()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动合并(十几个主播同时下播很常见)必须排队而不是并发
|
||||||
|
func TestMergeSlotSerializes(t *testing.T) {
|
||||||
|
if !tryTakeMergeSlot() {
|
||||||
|
t.Fatal("前置条件:任务位本该是空的")
|
||||||
|
}
|
||||||
|
const n = 4
|
||||||
|
order := make([]int, 0, n)
|
||||||
|
var mu sync.Mutex
|
||||||
|
done := make(chan struct{}, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
idx := i
|
||||||
|
go func() {
|
||||||
|
defer func() { done <- struct{}{} }()
|
||||||
|
// 和真实链路一致:自动合并会一直等到轮到自己
|
||||||
|
_ = runMergeExclusive(func() error {
|
||||||
|
mu.Lock()
|
||||||
|
order = append(order, idx)
|
||||||
|
mu.Unlock()
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
time.Sleep(60 * time.Millisecond) // 让它们先堆在门外
|
||||||
|
releaseMergeSlot()
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatalf("第 %d 个任务没跑完,队列卡住了", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(order) != n {
|
||||||
|
t.Fatalf("应当 %d 个任务都跑完,实际 %d 个", n, len(order))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 任务内部 panic 时也必须释放任务位、并且能翻成错误
|
||||||
|
// —— 否则卡片会永远停在「进行中」,队列也被锁死
|
||||||
|
func TestMergeExclusiveReleasesOnPanic(t *testing.T) {
|
||||||
|
err := runMergeExclusive(func() error { panic("模拟内部出错") })
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("内部 panic 应当被翻成错误返回")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "模拟内部出错") {
|
||||||
|
t.Errorf("错误信息里应带上原因,实际:%v", err)
|
||||||
|
}
|
||||||
|
if !tryTakeMergeSlot() {
|
||||||
|
t.Fatal("panic 之后任务位必须已经释放")
|
||||||
|
}
|
||||||
|
releaseMergeSlot()
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tiersForTest() []qualityTier {
|
||||||
|
// 故意乱序,验证排序
|
||||||
|
return []qualityTier{
|
||||||
|
{Name: "高清", Bitrate: 2000},
|
||||||
|
{Name: "蓝光 8M", Bitrate: 8000},
|
||||||
|
{Name: "标清", Bitrate: 1000},
|
||||||
|
{Name: "蓝光 4M", Bitrate: 4000},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSortQualitiesDesc(t *testing.T) {
|
||||||
|
got := sortQualitiesDesc(tiersForTest())
|
||||||
|
want := []string{"蓝光 8M", "蓝光 4M", "高清", "标清"}
|
||||||
|
for i, name := range want {
|
||||||
|
if got[i].Name != name {
|
||||||
|
t.Fatalf("第 %d 个应为 %s,实际 %s(%v)", i, name, got[i].Name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 不能改动传进来的切片
|
||||||
|
in := tiersForTest()
|
||||||
|
_ = sortQualitiesDesc(in)
|
||||||
|
if in[0].Name != "高清" {
|
||||||
|
t.Errorf("排序不该改动原切片:%v", in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认档位 = 自上往下第二档(不是最高档)
|
||||||
|
func TestDefaultQualityBitrate(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
tiers []qualityTier
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"四档取第二高", tiersForTest(), 4000},
|
||||||
|
{"三档取第二高(你给的例子)", []qualityTier{{"高清", 2000}, {"蓝光 8M", 8000}, {"蓝光 4M", 4000}}, 4000},
|
||||||
|
{"两档取低的那档", []qualityTier{{"蓝光 4M", 4000}, {"高清", 2000}}, 2000},
|
||||||
|
{"只有一档就用它", []qualityTier{{"高清", 2000}}, 2000},
|
||||||
|
{"没有档位返回 0", nil, 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := defaultQualityBitrate(c.tiers); got != c.want {
|
||||||
|
t.Errorf("%s:应为 %d,实际 %d", c.name, c.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQualityNameFor(t *testing.T) {
|
||||||
|
if got := qualityNameFor(tiersForTest(), 4000); got != "蓝光 4M" {
|
||||||
|
t.Errorf("4000 应匹配「蓝光 4M」,实际 %q", got)
|
||||||
|
}
|
||||||
|
if got := qualityNameFor(tiersForTest(), 9999); got != "" {
|
||||||
|
t.Errorf("匹配不上应返回空,实际 %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSameQualities(t *testing.T) {
|
||||||
|
a := tiersForTest()
|
||||||
|
b := []qualityTier{{"标清", 1000}, {"蓝光 4M", 4000}, {"高清", 2000}, {"蓝光 8M", 8000}}
|
||||||
|
if !sameQualities(a, b) {
|
||||||
|
t.Error("顺序不同但内容相同的档位列表应视为一样")
|
||||||
|
}
|
||||||
|
c := append([]qualityTier(nil), a[:3]...)
|
||||||
|
if sameQualities(a, c) {
|
||||||
|
t.Error("档位数量不同应视为不一样")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录档位时只缓存档位表,不动用户选的码率:
|
||||||
|
// 0 表示「自动、按实际档位取第二高」,主播换了档位表要能跟着走
|
||||||
|
func TestSetStreamerQualities(t *testing.T) {
|
||||||
|
savedConfig := config
|
||||||
|
savedStreamers := streamers.crt
|
||||||
|
defer func() {
|
||||||
|
config = savedConfig
|
||||||
|
streamers.crt = savedStreamers
|
||||||
|
}()
|
||||||
|
|
||||||
|
config.UIDs = uidList{
|
||||||
|
{UID: 1, Danmu: true, Record: true, Bitrate: 0}, // 自动
|
||||||
|
{UID: 2, Danmu: true, Record: true, Bitrate: 1000}, // 手动指定过
|
||||||
|
}
|
||||||
|
streamers.crt = map[int]streamer{
|
||||||
|
1: {UID: 1, Bitrate: 0},
|
||||||
|
2: {UID: 2, Bitrate: 1000},
|
||||||
|
}
|
||||||
|
|
||||||
|
if !setStreamerQualities(1, tiersForTest()) {
|
||||||
|
t.Error("第一次记录档位应该算改动了配置")
|
||||||
|
}
|
||||||
|
if config.UIDs[0].Bitrate != 0 {
|
||||||
|
t.Errorf("自动的主播不该被写死成具体码率,实际 %d", config.UIDs[0].Bitrate)
|
||||||
|
}
|
||||||
|
if len(config.UIDs[0].Qualities) != 4 || len(streamers.crt[1].Qualities) != 4 {
|
||||||
|
t.Errorf("档位没缓存到配置或运行时数据:%+v / %+v", config.UIDs[0], streamers.crt[1])
|
||||||
|
}
|
||||||
|
if streamers.crt[1].Bitrate != 0 {
|
||||||
|
t.Errorf("运行时数据的码率不该被改动,实际 %d", streamers.crt[1].Bitrate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动设过的同样不动
|
||||||
|
setStreamerQualities(2, tiersForTest())
|
||||||
|
if config.UIDs[1].Bitrate != 1000 {
|
||||||
|
t.Errorf("uid 2 手动设过 1000,不该被改成 %d", config.UIDs[1].Bitrate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同样的档位再来一次不算改动
|
||||||
|
if setStreamerQualities(1, tiersForTest()) {
|
||||||
|
t.Error("档位没变化时不该算改动")
|
||||||
|
}
|
||||||
|
// 档位变了才算改动
|
||||||
|
if !setStreamerQualities(1, []qualityTier{{"高清", 2000}, {"流畅", 500}}) {
|
||||||
|
t.Error("档位变化时应算改动")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按偏好决定实际用哪个档位:最高/第二高/最低按实际档位排序取,
|
||||||
|
// 自定义取不超过给定码率的最高档、都比它高时退回最低档
|
||||||
|
func TestResolveQualityBitrate(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
pref string
|
||||||
|
custom int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"最高档", qualityPrefTop, 0, 8000},
|
||||||
|
{"第二高(默认)", qualityPrefSecond, 0, 4000},
|
||||||
|
{"最低档", qualityPrefLowest, 0, 1000},
|
||||||
|
{"空偏好按默认的第二高", "", 0, 4000},
|
||||||
|
{"没见过的偏好也按第二高", "bogus", 0, 4000},
|
||||||
|
{"自定义取不超过上限的最高档", qualityPrefCustom, 3000, 2000},
|
||||||
|
{"自定义上限正好等于某一档", qualityPrefCustom, 4000, 4000},
|
||||||
|
{"自定义上限比所有档位都高就取最高档", qualityPrefCustom, 99999, 8000},
|
||||||
|
{"自定义上限比所有档位都低就退回最低档", qualityPrefCustom, 200, 1000},
|
||||||
|
{"自定义填 0 等于最低档", qualityPrefCustom, 0, 1000},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := resolveQualityBitrate(tiersForTest(), c.pref, c.custom); got != c.want {
|
||||||
|
t.Errorf("%s:应为 %d,实际 %d", c.name, c.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 没有档位表时:相对偏好返回 0(取流时退回最高档),自定义返回填的上限
|
||||||
|
if got := resolveQualityBitrate(nil, qualityPrefSecond, 0); got != 0 {
|
||||||
|
t.Errorf("没有档位表时相对偏好应返回 0,实际 %d", got)
|
||||||
|
}
|
||||||
|
if got := resolveQualityBitrate(nil, qualityPrefCustom, 2500); got != 2500 {
|
||||||
|
t.Errorf("没有档位表时自定义应返回填的 2500,实际 %d", got)
|
||||||
|
}
|
||||||
|
// 只有一档时:最高/第二高都是它,最低也是它
|
||||||
|
one := []qualityTier{{"高清", 2000}}
|
||||||
|
for _, pref := range []string{qualityPrefTop, qualityPrefSecond, qualityPrefLowest} {
|
||||||
|
if got := resolveQualityBitrate(one, pref, 0); got != 2000 {
|
||||||
|
t.Errorf("只有一档时 %s 应为 2000,实际 %d", pref, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 老配置只有 bitrate,读进来时要能推断出偏好:>0 是「自定义上限」,0 是默认的第二高
|
||||||
|
func TestMigrateQualityPref(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
pref string
|
||||||
|
bitrate int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"", 0, qualityPrefSecond},
|
||||||
|
{"", 5000, qualityPrefCustom},
|
||||||
|
{"", 1000, qualityPrefCustom},
|
||||||
|
{qualityPrefTop, 0, qualityPrefTop},
|
||||||
|
{qualityPrefLowest, 5000, qualityPrefLowest},
|
||||||
|
{qualityPrefCustom, 3000, qualityPrefCustom},
|
||||||
|
{" ", 0, qualityPrefSecond}, // 认不出来的值按默认
|
||||||
|
{"TOP", 0, qualityPrefSecond}, // 大小写不敏感不做,保持严格
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := migrateQualityPref(c.pref, c.bitrate); got != c.want {
|
||||||
|
t.Errorf("pref=%q bitrate=%d 应为 %s,实际 %s", c.pref, c.bitrate, c.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// normalizeQualityPref 对四种合法值原样返回
|
||||||
|
for _, p := range []string{qualityPrefTop, qualityPrefSecond, qualityPrefLowest, qualityPrefCustom} {
|
||||||
|
if got := normalizeQualityPref(p); got != p {
|
||||||
|
t.Errorf("%s 应原样返回,实际 %s", p, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 升级老配置:config.UIDs 和默认档位都要补上 qualityPref
|
||||||
|
func TestUpgradeQualityPrefs(t *testing.T) {
|
||||||
|
savedConfig := config
|
||||||
|
defer func() { config = savedConfig }()
|
||||||
|
|
||||||
|
config.UIDs = uidList{
|
||||||
|
{UID: 1}, // 老配置:bitrate 0 → 第二高
|
||||||
|
{UID: 2, Bitrate: 5000}, // 老配置:有码率 → 自定义
|
||||||
|
{UID: 3, QualityPref: qualityPrefTop}, // 已经是新配置 → 不动
|
||||||
|
}
|
||||||
|
config.DefaultQualityPref = ""
|
||||||
|
config.DefaultBitrate = 0
|
||||||
|
upgradeQualityPrefs()
|
||||||
|
if config.UIDs[0].QualityPref != qualityPrefSecond {
|
||||||
|
t.Errorf("uid 1 应补成 second,实际 %s", config.UIDs[0].QualityPref)
|
||||||
|
}
|
||||||
|
if config.UIDs[1].QualityPref != qualityPrefCustom {
|
||||||
|
t.Errorf("uid 2 应补成 custom,实际 %s", config.UIDs[1].QualityPref)
|
||||||
|
}
|
||||||
|
if config.UIDs[2].QualityPref != qualityPrefTop {
|
||||||
|
t.Errorf("uid 3 不该被改动,实际 %s", config.UIDs[2].QualityPref)
|
||||||
|
}
|
||||||
|
if config.DefaultQualityPref != qualityPrefSecond {
|
||||||
|
t.Errorf("默认档位应补成 second,实际 %s", config.DefaultQualityPref)
|
||||||
|
}
|
||||||
|
// 幂等
|
||||||
|
upgradeQualityPrefs()
|
||||||
|
if config.UIDs[1].QualityPref != qualityPrefCustom {
|
||||||
|
t.Error("重复升级不该改变结果")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改档位偏好:写配置 + 同步运行时数据
|
||||||
|
func TestSetQuality(t *testing.T) {
|
||||||
|
savedConfig := config
|
||||||
|
savedStreamers := streamers.crt
|
||||||
|
savedLocation := configFileLocation
|
||||||
|
oldDir := configDir
|
||||||
|
if configDir == nil {
|
||||||
|
configDir = new(string)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
config = savedConfig
|
||||||
|
streamers.crt = savedStreamers
|
||||||
|
configFileLocation = savedLocation
|
||||||
|
configDir = oldDir
|
||||||
|
}()
|
||||||
|
|
||||||
|
*configDir = t.TempDir()
|
||||||
|
configFileLocation = filepath.Join(*configDir, configFile)
|
||||||
|
|
||||||
|
config.UIDs = uidList{{UID: 7, QualityPref: qualityPrefSecond}}
|
||||||
|
streamers.crt = map[int]streamer{7: {UID: 7, QualityPref: qualityPrefSecond}}
|
||||||
|
|
||||||
|
if !setQuality(7, qualityPrefTop, 0) {
|
||||||
|
t.Fatal("改档位应该成功")
|
||||||
|
}
|
||||||
|
if config.UIDs[0].QualityPref != qualityPrefTop || streamers.crt[7].QualityPref != qualityPrefTop {
|
||||||
|
t.Errorf("档位偏好没同步:%+v / %+v", config.UIDs[0], streamers.crt[7])
|
||||||
|
}
|
||||||
|
// 自定义时码率上限也要存下来
|
||||||
|
if !setQuality(7, qualityPrefCustom, 2500) {
|
||||||
|
t.Fatal("改成自定义应该成功")
|
||||||
|
}
|
||||||
|
if streamers.crt[7].Bitrate != 2500 || config.UIDs[0].Bitrate != 2500 {
|
||||||
|
t.Errorf("自定义码率没存:%+v / %+v", config.UIDs[0], streamers.crt[7])
|
||||||
|
}
|
||||||
|
// 认不出来的偏好按默认处理,负数码率直接拒绝
|
||||||
|
setQuality(7, "bogus", 2500)
|
||||||
|
if streamers.crt[7].QualityPref != qualityPrefSecond {
|
||||||
|
t.Errorf("非法偏好应按默认第二高,实际 %s", streamers.crt[7].QualityPref)
|
||||||
|
}
|
||||||
|
if setQuality(7, qualityPrefCustom, -1) {
|
||||||
|
t.Error("负数码率应该被拒绝")
|
||||||
|
}
|
||||||
|
if setQuality(999, qualityPrefTop, 0) {
|
||||||
|
t.Error("不在列表里的 uid 应该失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动合并的通知靠文件名猜主播:只有一个名字对得上才认
|
||||||
|
func TestGuessUIDByFileName(t *testing.T) {
|
||||||
|
savedConfig := config
|
||||||
|
savedStreamers := streamers.crt
|
||||||
|
defer func() {
|
||||||
|
config = savedConfig
|
||||||
|
streamers.crt = savedStreamers
|
||||||
|
}()
|
||||||
|
|
||||||
|
config.UIDs = uidList{{UID: 1}, {UID: 2}, {UID: 3}}
|
||||||
|
streamers.crt = map[int]streamer{
|
||||||
|
1: {UID: 1, Name: "AC娘本体"},
|
||||||
|
2: {UID: 2, Name: "随便唠会"},
|
||||||
|
3: {UID: 3, Name: "阿"},
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
file string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"2024-10-14 00-33-32 AC娘本体 嘿嘿 醒了_弹幕合并.mp4", 1},
|
||||||
|
{"AC娘本体.mp4", 1},
|
||||||
|
{"2024-10-14 00-33-32 随便唠会 摸鱼.mp4", 2},
|
||||||
|
// 两个名字都在文件名里,猜不准就不猜
|
||||||
|
{"AC娘本体 和 随便唠会 联动.mp4", 0},
|
||||||
|
{"完全不认识的主播.mp4", 0},
|
||||||
|
// 名字只有一个字的不参与匹配,太容易误伤
|
||||||
|
{"阿 的录播.mp4", 0},
|
||||||
|
{"", 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := guessUIDByFileName(c.file); got != c.want {
|
||||||
|
t.Errorf("%q 应猜出 %d,实际 %d", c.file, c.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 档位要能存进 config.json 再读回来
|
||||||
|
func TestQualitiesConfigRoundTrip(t *testing.T) {
|
||||||
|
savedConfig := config
|
||||||
|
savedLocation := configFileLocation
|
||||||
|
oldDir := configDir
|
||||||
|
if configDir == nil {
|
||||||
|
configDir = new(string)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
config = savedConfig
|
||||||
|
configFileLocation = savedLocation
|
||||||
|
configDir = oldDir
|
||||||
|
}()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
*configDir = dir
|
||||||
|
configFileLocation = filepath.Join(dir, configFile)
|
||||||
|
|
||||||
|
body := `{"uids":[{"uid":23682490,"danmu":true,"record":true,"bitrate":4000,
|
||||||
|
"qualities":[{"name":"蓝光 8M","bitrate":8000},{"name":"蓝光 4M","bitrate":4000}]}]}`
|
||||||
|
if err := os.WriteFile(configFileLocation, []byte(body), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
config = configData{}
|
||||||
|
loadConfig()
|
||||||
|
|
||||||
|
if len(config.UIDs) != 1 {
|
||||||
|
t.Fatalf("应解析出 1 个主播,实际 %d", len(config.UIDs))
|
||||||
|
}
|
||||||
|
u := config.UIDs[0]
|
||||||
|
if u.Bitrate != 4000 || len(u.Qualities) != 2 || u.Qualities[0].Name != "蓝光 8M" {
|
||||||
|
t.Fatalf("档位没正确读出来:%+v", u)
|
||||||
|
}
|
||||||
|
if got := qualityNameFor(u.Qualities, u.Bitrate); got != "蓝光 4M" {
|
||||||
|
t.Errorf("当前档位名应为「蓝光 4M」,实际 %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再序列化一次,字段名要对得上(qualities / name / bitrate)
|
||||||
|
config.UIDs[0].Qualities = sortQualitiesDesc(config.UIDs[0].Qualities)
|
||||||
|
data, err := json.Marshal(config.UIDs[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, key := range []string{`"qualities"`, `"name"`, `"bitrate"`} {
|
||||||
|
if !json.Valid(data) || !strings.Contains(string(data), key) {
|
||||||
|
t.Errorf("序列化结果里应包含 %s:%s", key, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ func getFFmpeg() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if p, err := exec.LookPath("ffmpeg"); err == nil {
|
if p, err := exec.LookPath("ffmpeg"); err == nil {
|
||||||
return p
|
return absExe(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, dir := range []string{
|
for _, dir := range []string{
|
||||||
@@ -57,12 +57,25 @@ func getFFmpeg() string {
|
|||||||
} {
|
} {
|
||||||
p := filepath.Join(dir, name)
|
p := filepath.Join(dir, name)
|
||||||
if info, err := os.Stat(p); err == nil && !info.IsDir() {
|
if info, err := os.Stat(p); err == nil && !info.IsDir() {
|
||||||
return p
|
return absExe(p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 转成绝对路径再交给 exec:合并时 ffmpeg 的工作目录会被改成输出目录,
|
||||||
|
// 相对路径到那边就找不着了;而且 Windows 上 exec 会直接拒绝执行
|
||||||
|
// 「相对当前目录」找到的可执行文件(报错信息完全看不出跟路径有关)
|
||||||
|
func absExe(p string) string {
|
||||||
|
if p == "" || filepath.IsAbs(p) {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
if abs, err := filepath.Abs(p); err == nil {
|
||||||
|
return abs
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
// 检查设置里指定的 ffmpeg 路径,返回错误信息,空字符串表示可用(或没填)
|
// 检查设置里指定的 ffmpeg 路径,返回错误信息,空字符串表示可用(或没填)
|
||||||
func checkFFmpegPath(path string) string {
|
func checkFFmpegPath(path string) string {
|
||||||
p := strings.TrimSpace(path)
|
p := strings.TrimSpace(path)
|
||||||
@@ -86,26 +99,65 @@ func checkFFmpegPath(path string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取 uid 的直播源(flv),bitrate 为 0 时取码率最高的源,否则取码率不超过 bitrate(Kbps)的最高档
|
// 连到该主播的直播间并取直播流信息(获取直播源和探测码率档位都用它)
|
||||||
func getStreamURL(uid, bitrate int) (url string, quality string, err error) {
|
func newStreamInfo(uid int) (*acfundanmu.StreamInfo, error) {
|
||||||
var ac *acfundanmu.AcFunLive
|
var ac *acfundanmu.AcFunLive
|
||||||
|
var err error
|
||||||
err = runThrice(func() error {
|
err = runThrice(func() error {
|
||||||
ac, err = acfundanmu.NewAcFunLive(acfundanmu.SetLiverUID(int64(uid)))
|
ac, err = acfundanmu.NewAcFunLive(acfundanmu.SetLiverUID(int64(uid)))
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ac.GetStreamInfo(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把直播源列表转成码率档位
|
||||||
|
func streamTiers(list []acfundanmu.StreamURL) []qualityTier {
|
||||||
|
tiers := make([]qualityTier, 0, len(list))
|
||||||
|
for _, st := range list {
|
||||||
|
tiers = append(tiers, qualityTier{Name: st.QualityName, Bitrate: st.Bitrate})
|
||||||
|
}
|
||||||
|
return tiers
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取该主播当前的码率档位(没开播或拿不到直播源时返回错误)
|
||||||
|
func fetchStreamQualities(uid int) ([]qualityTier, error) {
|
||||||
|
info, err := newStreamInfo(uid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if info == nil || len(info.StreamList) == 0 {
|
||||||
|
return nil, fmt.Errorf("uid 为%d的主播现在没有可用的直播源(没开播?)", uid)
|
||||||
|
}
|
||||||
|
return streamTiers(info.StreamList), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 uid 的直播源(flv)。pref/custom 是页面上选的档位偏好(最高/第二高/最低/自定义),
|
||||||
|
// 按这次拿到的实际档位对应上去;拿不到档位表时退回最高档
|
||||||
|
func getStreamURL(uid int, pref string, custom int) (url string, quality string, err error) {
|
||||||
|
sInfo, err := newStreamInfo(uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
sInfo := ac.GetStreamInfo()
|
if sInfo == nil || len(sInfo.StreamList) == 0 {
|
||||||
if len(sInfo.StreamList) == 0 {
|
|
||||||
return "", "", fmt.Errorf("uid 为%d的主播没有可用的直播源", uid)
|
return "", "", fmt.Errorf("uid 为%d的主播没有可用的直播源", uid)
|
||||||
}
|
}
|
||||||
// StreamList 按码率升序排列
|
// 顺手把码率档位记下来:页面上要用它显示「这一档现在对应哪个档位名」,没开播时靠缓存显示
|
||||||
|
var tiers []qualityTier
|
||||||
|
if tiers = streamTiers(sInfo.StreamList); len(tiers) > 0 {
|
||||||
|
if setStreamerQualities(uid, tiers) {
|
||||||
|
saveConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := resolveQualityBitrate(tiers, pref, custom)
|
||||||
|
// StreamList 按码率升序排列,从高往低找选中的那一档
|
||||||
st := sInfo.StreamList[len(sInfo.StreamList)-1]
|
st := sInfo.StreamList[len(sInfo.StreamList)-1]
|
||||||
if bitrate > 0 {
|
if want > 0 {
|
||||||
st = sInfo.StreamList[0]
|
st = sInfo.StreamList[0]
|
||||||
for i := len(sInfo.StreamList) - 1; i >= 0; i-- {
|
for i := len(sInfo.StreamList) - 1; i >= 0; i-- {
|
||||||
if sInfo.StreamList[i].Bitrate <= bitrate {
|
if sInfo.StreamList[i].Bitrate <= want {
|
||||||
st = sInfo.StreamList[i]
|
st = sInfo.StreamList[i]
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -169,11 +221,6 @@ func stopRecord(uid int) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 退出直播视频录制相关操作
|
|
||||||
func quitRecord(liveID string) {
|
|
||||||
updateLiveInfo(liveID, func(i *liveInfo) { i.isRecording = false })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化直播视频录制
|
// 初始化直播视频录制
|
||||||
func (s streamer) initRecord(ctx context.Context, liveID, filename string) {
|
func (s streamer) initRecord(ctx context.Context, liveID, filename string) {
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -223,7 +270,7 @@ func (s streamer) recordLive(ctx context.Context, liveID, filename string) {
|
|||||||
|
|
||||||
var resumeFails int // 连续取不到直播源的次数
|
var resumeFails int // 连续取不到直播源的次数
|
||||||
for {
|
for {
|
||||||
url, quality, err := getStreamURL(s.UID, s.Bitrate)
|
url, quality, err := getStreamURL(s.UID, s.QualityPref, s.Bitrate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 断流重连时直播源往往要过几秒才回来,窗口内多试几次,别把一场直播切成两段
|
// 断流重连时直播源往往要过几秒才回来,窗口内多试几次,别把一场直播切成两段
|
||||||
if resumeFails < maxResumeRetry && ctx.Err() == nil && s.waitResume(ctx) {
|
if resumeFails < maxResumeRetry && ctx.Err() == nil && s.waitResume(ctx) {
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"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,
|
||||||
|
// 只有合并渲染前会重排,所以磁盘上的 .ass/.nick.ass/.jsonl 一个字节都不变。
|
||||||
|
func TestLiveRecordingPathUnaffected(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := acfundanmu.SubConfig{Title: "测试直播", PlayResX: 1920, PlayResY: 1080,
|
||||||
|
FontSize: 48, StartTime: 1700000000000000000}
|
||||||
|
ass := filepath.Join(dir, "live.ass")
|
||||||
|
w := newDanmuWriter("liveid", "stream", cfg, ass)
|
||||||
|
|
||||||
|
texts := []string{"来啦", "这首歌好听", "主播加油啊", "打卡打卡", "哈哈哈哈哈哈哈哈哈",
|
||||||
|
"这条弹幕稍微长一点看看弹道到底怎么排"}
|
||||||
|
for i := 0; i < 500; i++ {
|
||||||
|
relMs := int64(i) * 220 // 平均 0.22 秒一条,够密集
|
||||||
|
c := &acfundanmu.Comment{Content: texts[i%len(texts)]}
|
||||||
|
c.SendTime = (cfg.StartTime + relMs*1e6) / 1e6
|
||||||
|
c.UserID = int64(2000 + i%9)
|
||||||
|
c.Nickname = "观众" + string(rune('A'+i%9))
|
||||||
|
c.Avatar = "http://example.invalid/a.png"
|
||||||
|
w.write(c)
|
||||||
|
}
|
||||||
|
w.close()
|
||||||
|
|
||||||
|
f, err := os.Open(filepath.Join(dir, "live.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
sc.Buffer(make([]byte, 1<<20), 1<<20)
|
||||||
|
var meta danmuMeta
|
||||||
|
first := true
|
||||||
|
var recs []danmuRecord
|
||||||
|
for sc.Scan() {
|
||||||
|
if first {
|
||||||
|
json.Unmarshal(sc.Bytes(), &meta)
|
||||||
|
first = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var r danmuRecord
|
||||||
|
if json.Unmarshal(sc.Bytes(), &r) == nil {
|
||||||
|
recs = append(recs, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type snap struct{ lane, width int }
|
||||||
|
before := make([]snap, len(recs))
|
||||||
|
for i, r := range recs {
|
||||||
|
before[i] = snap{r.Lane, r.Width}
|
||||||
|
}
|
||||||
|
|
||||||
|
relayoutLanes(recs, meta)
|
||||||
|
|
||||||
|
laneDiff, widthDiff := 0, 0
|
||||||
|
for i, r := range recs {
|
||||||
|
if r.Lane != before[i].lane {
|
||||||
|
laneDiff++
|
||||||
|
if laneDiff <= 3 {
|
||||||
|
t.Logf("第 %d 条弹道变了:%d → %d", i, before[i].lane, r.Lane)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.Width != before[i].width {
|
||||||
|
widthDiff++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("录制产生 %d 条弹幕(含空内容 %d 条),重排后弹道变化 %d 条、宽度变化 %d 条",
|
||||||
|
len(recs), countEmpty(recs), laneDiff, widthDiff)
|
||||||
|
if laneDiff != 0 || widthDiff != 0 {
|
||||||
|
t.Errorf("正常录制的弹幕重排后不应有任何变化")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func countEmpty(recs []danmuRecord) int {
|
||||||
|
n := 0
|
||||||
|
for _, r := range recs {
|
||||||
|
if r.Content == "" {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 写盘出错必须能被发现 ====================
|
||||||
|
// 弹幕是边录边写的,写盘失败(磁盘满、外接盘/网络盘掉线)如果没人管,
|
||||||
|
// 程序会照常宣告「录制完成」并自动合并 —— 出来的成片缺一大截弹幕,
|
||||||
|
// 而日志里什么都看不出来。下面几条例外情况锁住「失败要浮出来」。
|
||||||
|
|
||||||
|
// 写入失败必须被记下来,并且在 close() 时交出来
|
||||||
|
func TestDanmuWriteErrorSurfacedOnClose(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := acfundanmu.SubConfig{Title: "测试直播", PlayResX: 1920, PlayResY: 1080,
|
||||||
|
FontSize: 48, StartTime: 1700000000000000000}
|
||||||
|
ass := filepath.Join(dir, "live.ass")
|
||||||
|
w := newDanmuWriter("liveid", "stream", cfg, ass)
|
||||||
|
if w.failed() {
|
||||||
|
t.Fatalf("刚建好的写入器不该有错误,实际:%v", w.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用「写到已关闭的句柄」来走同一条错误路径(和磁盘满、盘掉线一样,
|
||||||
|
// 都是 os.File.Write 返回 error)。换掉之前先把原来的句柄关掉,别泄漏
|
||||||
|
if err := w.jsonl.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dead, err := os.Open(filepath.Join(dir, "live.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := dead.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.jsonl = dead
|
||||||
|
|
||||||
|
c := &acfundanmu.Comment{Content: "这条应该写失败"}
|
||||||
|
c.SendTime = (cfg.StartTime + int64(500)*1e6) / 1e6
|
||||||
|
c.UserID = 7
|
||||||
|
c.Nickname = "观众"
|
||||||
|
w.write(c)
|
||||||
|
|
||||||
|
if !w.failed() {
|
||||||
|
t.Fatal("写入失败之后 failed() 应为 true(改之前错误是被丢掉的)")
|
||||||
|
}
|
||||||
|
err = w.close()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("close() 必须把写盘错误交出来,实际返回 nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "jsonl") {
|
||||||
|
t.Errorf("错误信息里要能看出是哪个文件出的问题,实际:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只记第一个错:磁盘满了之后每写一条都会失败,全记下来会把日志刷爆
|
||||||
|
func TestDanmuWriteKeepsOnlyFirstError(t *testing.T) {
|
||||||
|
w := &danmuWriter{}
|
||||||
|
w.fail("第一次", errors.New("磁盘满了"))
|
||||||
|
first := w.err
|
||||||
|
w.fail("第二次", errors.New("还是写不进去"))
|
||||||
|
w.fail("第三次", errors.New("依旧写不进去"))
|
||||||
|
if w.err != first {
|
||||||
|
t.Fatalf("错误应保持在第一个,实际被后来的覆盖了:%v", w.err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.err.Error(), "磁盘满了") {
|
||||||
|
t.Errorf("应保留第一个错误的原因,实际:%v", w.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常录制不能被误判成失败(这是「加检查」最容易踩的反向坑)
|
||||||
|
func TestDanmuWriteNoFalseAlarm(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := acfundanmu.SubConfig{Title: "测试直播", PlayResX: 1920, PlayResY: 1080,
|
||||||
|
FontSize: 48, StartTime: 1700000000000000000}
|
||||||
|
w := newDanmuWriter("liveid", "stream", cfg, filepath.Join(dir, "ok.ass"))
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
c := &acfundanmu.Comment{Content: "正常弹幕"}
|
||||||
|
c.SendTime = (cfg.StartTime + int64(i)*220*1e6) / 1e6
|
||||||
|
c.UserID = int64(100 + i)
|
||||||
|
c.Nickname = "观众"
|
||||||
|
w.write(c)
|
||||||
|
}
|
||||||
|
if w.failed() {
|
||||||
|
t.Fatalf("正常写入不该判失败:%v", w.err)
|
||||||
|
}
|
||||||
|
if err := w.close(); err != nil {
|
||||||
|
t.Fatalf("正常写完后 close() 应返回 nil,实际:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹幕文件写坏之后必须跳过自动合并:只是打一条日志的话,
|
||||||
|
// 照样会合成一个「缺了一大截弹幕」的成片,而且是静默的
|
||||||
|
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
|
||||||
|
runBackground = oldRun
|
||||||
|
})
|
||||||
|
config.AutoMerge = true
|
||||||
|
mainCtx = context.Background()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
video := filepath.Join(dir, "v.mp4")
|
||||||
|
ass := filepath.Join(dir, "v.ass")
|
||||||
|
if err := os.WriteFile(video, []byte("fake"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(ass, []byte("[Script Info]"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试里没跑 initialize(),这张表还没建起来
|
||||||
|
lInfoMap.Lock()
|
||||||
|
if lInfoMap.info == nil {
|
||||||
|
lInfoMap.info = make(map[string]liveInfo)
|
||||||
|
}
|
||||||
|
lInfoMap.Unlock()
|
||||||
|
|
||||||
|
// 对照组:一切都正常时应该触发合并
|
||||||
|
good := "good-live-id"
|
||||||
|
lInfoMap.Lock()
|
||||||
|
lInfoMap.info[good] = liveInfo{LiveID: good, uid: 1, assFile: ass, firstRecordFile: video,
|
||||||
|
recordStartNs: time.Now().UnixNano(), recordSegments: []recordSegment{{file: video, startNs: time.Now().UnixNano()}}}
|
||||||
|
lInfoMap.Unlock()
|
||||||
|
finishPart(good, true)
|
||||||
|
finishPart(good, false)
|
||||||
|
lInfoMap.RLock()
|
||||||
|
started := lInfoMap.info[good].mergeStarted
|
||||||
|
lInfoMap.RUnlock()
|
||||||
|
if !started {
|
||||||
|
t.Fatal("对照组:条件都满足时应当触发自动合并(否则这条测试本身没意义)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实验组:同样条件,但弹幕写盘出过错
|
||||||
|
bad := "bad-live-id"
|
||||||
|
lInfoMap.Lock()
|
||||||
|
lInfoMap.info[bad] = liveInfo{LiveID: bad, uid: 2, assFile: ass, firstRecordFile: video,
|
||||||
|
danmuWriteFailed: true,
|
||||||
|
recordStartNs: time.Now().UnixNano(), recordSegments: []recordSegment{{file: video, startNs: time.Now().UnixNano()}}}
|
||||||
|
lInfoMap.Unlock()
|
||||||
|
finishPart(bad, true)
|
||||||
|
finishPart(bad, false)
|
||||||
|
lInfoMap.RLock()
|
||||||
|
got := lInfoMap.info[bad].mergeStarted
|
||||||
|
lInfoMap.RUnlock()
|
||||||
|
if got {
|
||||||
|
t.Error("弹幕文件写坏过,不该触发自动合并(否则会合成一个缺弹幕的成片)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 一块里同一个人有多条弹幕、但其中一条整条落在块外(会被裁掉)时,
|
||||||
|
// 头像的 split 份数必须和真正用到的份数一致 —— 否则 split 会多出一个没人接收的输出 pad,
|
||||||
|
// ffmpeg 直接以 EINVAL(Windows 上就是 exit status 0xffffffea)退出,
|
||||||
|
// 报错完全看不出跟弹幕有关,还会连累三个编码器挨个"失败"。
|
||||||
|
func TestRenderChunkMatchesSplitWithVisibleDanmu(t *testing.T) {
|
||||||
|
ffmpegFile := getFFmpeg()
|
||||||
|
if ffmpegFile == "" {
|
||||||
|
t.Skip("找不到 ffmpeg,跳过")
|
||||||
|
}
|
||||||
|
withJobFlags(t, true, false, false)
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// 头像
|
||||||
|
av := filepath.Join(dir, "av.png")
|
||||||
|
f, err := os.Create(av)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := png.Encode(f, image.NewRGBA(image.Rect(0, 0, 16, 16))); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
// 同一个 uid 两条弹幕:一条在块内可见,另一条时间远在块外
|
||||||
|
uid := int64(7)
|
||||||
|
chunk := danmuChunk{start: 0, end: 1, records: []danmuRecord{
|
||||||
|
{Time: 0.2, UID: uid, Name: "甲", Content: "块内可见的", Lane: 0, Width: 100},
|
||||||
|
{Time: 30.0, UID: uid, Name: "甲", Content: "整条在块外的", Lane: 1, Width: 100},
|
||||||
|
}}
|
||||||
|
|
||||||
|
ctx := &burnContext{
|
||||||
|
ffmpegFile: ffmpegFile,
|
||||||
|
outFile: filepath.Join(dir, "out.mp4"),
|
||||||
|
meta: danmuMeta{PlayResX: 160, PlayResY: 120, FontSize: 20},
|
||||||
|
avatars: map[int64]string{uid: av},
|
||||||
|
dir: dir,
|
||||||
|
encoder: "libx264", // 直接指定软编,别让 QSV 的兼容性干扰这条用例
|
||||||
|
}
|
||||||
|
out := filepath.Join(dir, "chunk.mov")
|
||||||
|
if err := ctx.renderChunk(chunk, "chunk.ass", out, false, nil); err != nil {
|
||||||
|
t.Fatalf("同一个人有两条弹幕、其中一条在块外时不该渲染失败:%v", err)
|
||||||
|
}
|
||||||
|
if !fileExists(out) {
|
||||||
|
t.Fatalf("应当产出文件:%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
+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
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -67,6 +68,9 @@ type liveInfo struct {
|
|||||||
danmuStopped bool // 弹幕是否被用户手动停止(手动停止不触发自动合并)
|
danmuStopped bool // 弹幕是否被用户手动停止(手动停止不触发自动合并)
|
||||||
recordStopped bool // 视频是否被用户手动停止
|
recordStopped bool // 视频是否被用户手动停止
|
||||||
mergeStarted bool // 自动合并是否已经启动
|
mergeStarted bool // 自动合并是否已经启动
|
||||||
|
// 弹幕文件在写盘时出过错(磁盘满、网络盘断开等)。文件是不完整的,
|
||||||
|
// 拿它合并会得到一个缺弹幕的成片,所以本场跳过自动合并
|
||||||
|
danmuWriteFailed bool
|
||||||
doneAt int64 // 最近一次录制结束的时刻,纳秒 Unix(用于延迟清理这条记录)
|
doneAt int64 // 最近一次录制结束的时刻,纳秒 Unix(用于延迟清理这条记录)
|
||||||
resumeUntil int64 // 断流后等待直播恢复的截止时刻,纳秒 Unix;0 表示没在等待
|
resumeUntil int64 // 断流后等待直播恢复的截止时刻,纳秒 Unix;0 表示没在等待
|
||||||
}
|
}
|
||||||
@@ -103,17 +107,37 @@ func checkErr(err error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 后台任务真正怎么起。单独抽成一个变量是为了让测试能换成「可等待」的版本:
|
||||||
|
// fire-and-forget 的 goroutine 漏到测试结束之后,会在下一个测试改全局配置时读它们,
|
||||||
|
// -race 会判成数据竞争
|
||||||
|
var runBackground = func(f func()) { go f() }
|
||||||
|
|
||||||
// 后台任务的统一入口:兜底 recover,避免单个任务的 panic 终止整个进程
|
// 后台任务的统一入口:兜底 recover,避免单个任务的 panic 终止整个进程
|
||||||
// name 用于日志里指出是哪个任务出错,f 里可以继续做自己的 recover
|
// name 用于日志里指出是哪个任务出错,f 里可以继续做自己的 recover
|
||||||
func safeGo(name string, f func()) {
|
func safeGo(name string, f func()) {
|
||||||
go func() {
|
runBackground(func() {
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := recover(); err != nil {
|
if err := recover(); err != nil {
|
||||||
lPrintErrf("%s 发生错误并已恢复,其它录制任务不受影响:%v", name, err)
|
lPrintErrf("%s 发生错误并已恢复,其它录制任务不受影响:%v", name, err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
f()
|
f()
|
||||||
}()
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不需要重试的错误:再试一遍也只是重复同样的失败(比如头像本身就超出体积/尺寸上限),
|
||||||
|
// 硬重试只会白白把几 MB 的响应重新下一遍
|
||||||
|
type noRetryError struct{ err error }
|
||||||
|
|
||||||
|
func (e noRetryError) Error() string { return e.err.Error() }
|
||||||
|
func (e noRetryError) Unwrap() error { return e.err }
|
||||||
|
|
||||||
|
// 把错误标记成「重试也没用」
|
||||||
|
func noRetry(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return noRetryError{err}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试运行,三次出错后结束运行
|
// 尝试运行,三次出错后结束运行
|
||||||
@@ -121,6 +145,10 @@ func runThrice(f func() error) error {
|
|||||||
var err error
|
var err error
|
||||||
for retry := 0; retry < 3; retry++ {
|
for retry := 0; retry < 3; retry++ {
|
||||||
if err = f(); err != nil {
|
if err = f(); err != nil {
|
||||||
|
var nr noRetryError
|
||||||
|
if errors.As(err, &nr) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
log.Printf("%v", err)
|
log.Printf("%v", err)
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
@@ -225,13 +253,6 @@ func getLiveInfo(liveID string) (liveInfo, bool) {
|
|||||||
return liveInfo{}, false
|
return liveInfo{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将 info 放进 lInfoMap 里
|
|
||||||
func setLiveInfo(info liveInfo) {
|
|
||||||
lInfoMap.Lock()
|
|
||||||
defer lInfoMap.Unlock()
|
|
||||||
lInfoMap.info[info.LiveID] = info
|
|
||||||
}
|
|
||||||
|
|
||||||
// 原地更新 liveInfo 的部分字段,不覆盖其他字段(弹幕和视频录制共用同一个 liveInfo)
|
// 原地更新 liveInfo 的部分字段,不覆盖其他字段(弹幕和视频录制共用同一个 liveInfo)
|
||||||
func updateLiveInfo(liveID string, f func(*liveInfo)) {
|
func updateLiveInfo(liveID string, f func(*liveInfo)) {
|
||||||
lInfoMap.Lock()
|
lInfoMap.Lock()
|
||||||
@@ -252,14 +273,6 @@ func isDanmu(liveID string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据 liveID 查询是否正在录制直播视频
|
|
||||||
func isRecording(liveID string) bool {
|
|
||||||
if info, ok := getLiveInfo(liveID); ok {
|
|
||||||
return info.isRecording
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 跨盘搬运成片时的临时后缀:拷一半失败也能一眼看出不是成品
|
// 跨盘搬运成片时的临时后缀:拷一半失败也能一眼看出不是成品
|
||||||
const partSuffix = ".acfunlive.part"
|
const partSuffix = ".acfunlive.part"
|
||||||
|
|
||||||
@@ -282,7 +295,12 @@ func placeOutput(tmp, out string) error {
|
|||||||
// 跨盘时 os.Rename 会失败(Windows 上尤其明确),退回拷贝:
|
// 跨盘时 os.Rename 会失败(Windows 上尤其明确),退回拷贝:
|
||||||
// 先拷成 .part 再改名,中途失败也不会在成片目录留下像成品的文件
|
// 先拷成 .part 再改名,中途失败也不会在成片目录留下像成品的文件
|
||||||
part := out + partSuffix
|
part := out + partSuffix
|
||||||
if err := copyFile(tmp, part); err != nil {
|
total := fileSizeOrZero(tmp)
|
||||||
|
if err := copyFileWith(tmp, part, func(done int64) {
|
||||||
|
if total > 0 {
|
||||||
|
jobSetPhasePercent(float64(done) / float64(total))
|
||||||
|
}
|
||||||
|
}); err != nil {
|
||||||
_ = os.Remove(part)
|
_ = os.Remove(part)
|
||||||
return fmt.Errorf("拷贝到成片目录 %s 失败:%v", filepath.Dir(out), err)
|
return fmt.Errorf("拷贝到成片目录 %s 失败:%v", filepath.Dir(out), err)
|
||||||
}
|
}
|
||||||
@@ -296,6 +314,11 @@ func placeOutput(tmp, out string) error {
|
|||||||
|
|
||||||
// 拷贝文件,跨盘搬运成片时用
|
// 拷贝文件,跨盘搬运成片时用
|
||||||
func copyFile(src, dst string) error {
|
func copyFile(src, dst string) error {
|
||||||
|
return copyFileWith(src, dst, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拷贝文件。onBytes 不为空时按累计已拷贝字节回调,跨盘搬运成片时用来报进度
|
||||||
|
func copyFileWith(src, dst string, onBytes func(int64)) error {
|
||||||
in, err := os.Open(src)
|
in, err := os.Open(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -305,9 +328,27 @@ func copyFile(src, dst string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := io.Copy(out, in); err != nil {
|
buf := make([]byte, 1<<20)
|
||||||
|
var done int64
|
||||||
|
for {
|
||||||
|
n, rerr := in.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if _, werr := out.Write(buf[:n]); werr != nil {
|
||||||
_ = out.Close()
|
_ = out.Close()
|
||||||
return err
|
return werr
|
||||||
|
}
|
||||||
|
done += int64(n)
|
||||||
|
if onBytes != nil {
|
||||||
|
onBytes(done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rerr == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if rerr != nil {
|
||||||
|
_ = out.Close()
|
||||||
|
return rerr
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out.Close()
|
return out.Close()
|
||||||
}
|
}
|
||||||
@@ -322,10 +363,19 @@ func cleanTempFiles(dir string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
|
name := e.Name()
|
||||||
|
// 预缩放头像是个目录(chunkAvatarDir),别被整个跳过
|
||||||
if e.IsDir() {
|
if e.IsDir() {
|
||||||
|
if strings.HasPrefix(name, chunkTempPrefix) {
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
if err := os.RemoveAll(path); err == nil {
|
||||||
|
lPrintln("清理上次留下的临时文件夹:" + name)
|
||||||
|
} else {
|
||||||
|
lPrintWarnf("临时文件夹 %s 现在删不掉,下次启动会自动清理:%v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := e.Name()
|
|
||||||
if !strings.HasPrefix(name, chunkTempPrefix) && !strings.HasSuffix(name, partSuffix) {
|
if !strings.HasPrefix(name, chunkTempPrefix) && !strings.HasSuffix(name, partSuffix) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+291
@@ -2,10 +2,17 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 测试期间换一个管理口令
|
// 测试期间换一个管理口令
|
||||||
@@ -155,3 +162,287 @@ func TestAuthTokenCandidatesOrder(t *testing.T) {
|
|||||||
t.Fatalf("非 Bearer 的 Authorization 不该当候选:%q", got)
|
t.Fatalf("非 Bearer 的 Authorization 不该当候选:%q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 口令错误记录的容量 ====================
|
||||||
|
// IP 是外部给的:只靠「过期清理」挡不住换着地址撞口令的情况,
|
||||||
|
// 必须要有硬上限。这些用例锁住这一点。
|
||||||
|
|
||||||
|
// 换着来源持续撞口令,表必须被压在上限以内,不能一直涨
|
||||||
|
func TestAuthFailTableHasHardCap(t *testing.T) {
|
||||||
|
saved, savedSweep := authFail.m, authFail.sinceSweep
|
||||||
|
t.Cleanup(func() {
|
||||||
|
authFail.Lock()
|
||||||
|
authFail.m, authFail.sinceSweep = saved, savedSweep
|
||||||
|
authFail.Unlock()
|
||||||
|
})
|
||||||
|
authFail.Lock()
|
||||||
|
authFail.m, authFail.sinceSweep = nil, 0
|
||||||
|
authFail.Unlock()
|
||||||
|
|
||||||
|
// 每个都是新来源、且都是「刚刚」失败过 —— 过期清理一条都删不掉的情况
|
||||||
|
for i := 0; i < authFailMaxEntries*2; i++ {
|
||||||
|
authFailDelay(fmt.Sprintf("198.51.100.%d", i))
|
||||||
|
}
|
||||||
|
got := 0
|
||||||
|
authFail.Lock()
|
||||||
|
got = len(authFail.m)
|
||||||
|
authFail.Unlock()
|
||||||
|
if got > authFailMaxEntries {
|
||||||
|
t.Errorf("记录数应被压在上限 %d 以内,实际 %d(换 IP 撞口令会把内存写满)",
|
||||||
|
authFailMaxEntries, got)
|
||||||
|
}
|
||||||
|
t.Logf(" %d 个新来源撞完,表内留下 %d 条(上限 %d)", authFailMaxEntries*2, got, authFailMaxEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超过过期时间的记录要被扫掉,不是在表里一直躺着
|
||||||
|
func TestAuthFailExpiredEntriesGetSwept(t *testing.T) {
|
||||||
|
saved, savedSweep := authFail.m, authFail.sinceSweep
|
||||||
|
t.Cleanup(func() {
|
||||||
|
authFail.Lock()
|
||||||
|
authFail.m, authFail.sinceSweep = saved, savedSweep
|
||||||
|
authFail.Unlock()
|
||||||
|
})
|
||||||
|
authFail.Lock()
|
||||||
|
authFail.m, authFail.sinceSweep = nil, 0
|
||||||
|
authFail.Unlock()
|
||||||
|
|
||||||
|
for i := 0; i < authFailSweepEvery+5; i++ {
|
||||||
|
authFailDelay(fmt.Sprintf("203.0.113.%d", i))
|
||||||
|
}
|
||||||
|
authFail.Lock()
|
||||||
|
before := len(authFail.m)
|
||||||
|
// 把记录的最后时间推到过期之前
|
||||||
|
for k, v := range authFail.m {
|
||||||
|
authFail.m[k] = authFailEntry{count: v.count, last: time.Now().Add(-2 * authFailExpire)}
|
||||||
|
}
|
||||||
|
authFail.Unlock()
|
||||||
|
|
||||||
|
sweepAuthFail(time.Now())
|
||||||
|
authFail.Lock()
|
||||||
|
after := len(authFail.m)
|
||||||
|
authFail.Unlock()
|
||||||
|
if after != 0 {
|
||||||
|
t.Errorf("已经全部过期的记录应被扫干净,扫之前 %d 条,扫之后 %d 条", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扫表必须是自动发生的:不要求调用方记着手动清理,
|
||||||
|
// 混在里面的过期项应该在若干次记录之后自己消失
|
||||||
|
func TestAuthFailSweepsPeriodically(t *testing.T) {
|
||||||
|
saved, savedSweep := authFail.m, authFail.sinceSweep
|
||||||
|
t.Cleanup(func() {
|
||||||
|
authFail.Lock()
|
||||||
|
authFail.m, authFail.sinceSweep = saved, savedSweep
|
||||||
|
authFail.Unlock()
|
||||||
|
})
|
||||||
|
authFail.Lock()
|
||||||
|
authFail.m, authFail.sinceSweep = nil, 0
|
||||||
|
authFail.Unlock()
|
||||||
|
|
||||||
|
// 直接往里塞一条早就过期了的记录(绕过 authFailDelay,模拟「以前留下的」)
|
||||||
|
authFail.Lock()
|
||||||
|
if authFail.m == nil {
|
||||||
|
authFail.m = make(map[string]authFailEntry)
|
||||||
|
}
|
||||||
|
authFail.m["203.0.113.99"] = authFailEntry{count: 1, last: time.Now().Add(-2 * authFailExpire)}
|
||||||
|
authFail.Unlock()
|
||||||
|
|
||||||
|
// 之后只要还在有人失败,过一阵子它就应该被清掉
|
||||||
|
for i := 0; i < authFailSweepEvery+1; i++ {
|
||||||
|
authFailDelay(fmt.Sprintf("198.51.100.%d", i))
|
||||||
|
}
|
||||||
|
|
||||||
|
authFail.Lock()
|
||||||
|
_, still := authFail.m["203.0.113.99"]
|
||||||
|
authFail.Unlock()
|
||||||
|
if still {
|
||||||
|
t.Errorf("记了 %d 次之后,过期记录应当已经被自动扫掉", authFailSweepEvery+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一个来源的计数不能无限涨(临界点是延迟早已封顶,再涨只是让数字溢出)
|
||||||
|
func TestAuthFailCountCapped(t *testing.T) {
|
||||||
|
saved, savedSweep := authFail.m, authFail.sinceSweep
|
||||||
|
t.Cleanup(func() {
|
||||||
|
authFail.Lock()
|
||||||
|
authFail.m, authFail.sinceSweep = saved, savedSweep
|
||||||
|
authFail.Unlock()
|
||||||
|
})
|
||||||
|
authFail.Lock()
|
||||||
|
authFail.m, authFail.sinceSweep = nil, 0
|
||||||
|
authFail.Unlock()
|
||||||
|
|
||||||
|
for i := 0; i < authFailMaxCount+50; i++ {
|
||||||
|
authFailDelay("192.0.2.9")
|
||||||
|
}
|
||||||
|
authFail.Lock()
|
||||||
|
c := authFail.m["192.0.2.9"].count
|
||||||
|
authFail.Unlock()
|
||||||
|
if c > authFailMaxCount {
|
||||||
|
t.Errorf("同一来源的次数应封顶在 %d,实际 %d", authFailMaxCount, c)
|
||||||
|
}
|
||||||
|
if d := authFailDelay("192.0.2.9"); d != authMaxDelay {
|
||||||
|
t.Errorf("打满之后延迟应封顶在 %v,实际 %v", authMaxDelay, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 客户端断开时延迟等待必须立刻返回,不能傻等满 2 秒
|
||||||
|
func TestAwaitAuthDelayStopsOnCancel(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
go func() {
|
||||||
|
time.Sleep(80 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
start := time.Now()
|
||||||
|
waited := awaitAuthDelay(ctx, 2*time.Second)
|
||||||
|
used := time.Since(start)
|
||||||
|
if waited {
|
||||||
|
t.Error("上游取消时 awaitAuthDelay 应返回 false")
|
||||||
|
}
|
||||||
|
if used > time.Second {
|
||||||
|
t.Errorf("上游取消后应立刻返回,实际等了 %v", used)
|
||||||
|
}
|
||||||
|
// 正常等待不受影响
|
||||||
|
if !awaitAuthDelay(context.Background(), 10*time.Millisecond) {
|
||||||
|
t.Error("没被取消时应当正常等完再返回 true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
webInputIDRe = regexp.MustCompile(`<(?:input|select|textarea)[^>]*\bid="([A-Za-z0-9_]+)"`)
|
||||||
|
webSettingFieldsRe = regexp.MustCompile(`var settingFields = \[([^\]]*)\]`)
|
||||||
|
webNumberFieldsRe = regexp.MustCompile(`var numberFields = \{([^}]*)\}`)
|
||||||
|
webQuotedRe = regexp.MustCompile(`"([A-Za-z0-9_]+)"`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 设置页里每个能改的输入框都必须在 settingFields 里登记:漏登记的后果是「改了、按了保存,
|
||||||
|
// 值却没被提交」,3 秒后刷新又被刷回原值 —— 页面看起来完全正常,也没有任何报错。
|
||||||
|
// 反过来,settingFields 里写的 id 如果页面上不存在,refresh 时就静默填不进去。
|
||||||
|
//
|
||||||
|
// settingFields / numberFields 都是页面里的 JS 变量(整页在 webuiHTML 这个原始串里),
|
||||||
|
// 所以这里直接把它们从串里抠出来比对,不另维护一份名单
|
||||||
|
func TestWebuiSettingFieldsMatchInputs(t *testing.T) {
|
||||||
|
ids := map[string]bool{}
|
||||||
|
for _, m := range webInputIDRe.FindAllStringSubmatch(webuiHTML, -1) {
|
||||||
|
ids[m[1]] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
m := webSettingFieldsRe.FindStringSubmatch(webuiHTML)
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("没能在页面里找到 settingFields 列表")
|
||||||
|
}
|
||||||
|
settings := map[string]bool{}
|
||||||
|
for _, q := range webQuotedRe.FindAllStringSubmatch(m[1], -1) {
|
||||||
|
settings[q[1]] = true
|
||||||
|
if !ids[q[1]] {
|
||||||
|
t.Errorf("settingFields 里的 %q 在页面上找不到对应的输入框", q[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 这些输入框刻意不进设置:要么是手动合并卡片自己的参数(随合并请求提交),
|
||||||
|
// 要么是路径选择弹窗 / 口令框的内部状态
|
||||||
|
notSettings := map[string]string{
|
||||||
|
"authTokenNew": "重新生成口令,走单独的接口",
|
||||||
|
"authTokenView": "只读展示当前口令",
|
||||||
|
"tokenInput": "页面解锁框,不是设置项",
|
||||||
|
"newuid": "添加主播,走主播接口",
|
||||||
|
"pickHidden": "路径选择弹窗的内部状态",
|
||||||
|
"pickPath": "路径选择弹窗的内部状态",
|
||||||
|
"mrgVideoPick": "手动合并:视频列表,随合并请求提交",
|
||||||
|
"tcFilePick": "转码:待选文件列表,随转码请求提交",
|
||||||
|
"tcPaths": "转码:手填的文件路径列表,随转码请求提交",
|
||||||
|
"tcOutDir": "转码:输出目录,随转码请求提交",
|
||||||
|
}
|
||||||
|
for id := range ids {
|
||||||
|
if settings[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := notSettings[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Errorf("页面上的 %q 既不在 settingFields 里,也没说明为什么不算设置项:改了它不会被保存", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// numberFields 里的 id 必须都是设置项,否则 parseInt 的结果没人用
|
||||||
|
n := webNumberFieldsRe.FindStringSubmatch(webuiHTML)
|
||||||
|
if n == nil {
|
||||||
|
t.Fatal("没能在页面里找到 numberFields 列表")
|
||||||
|
}
|
||||||
|
for _, m := range regexp.MustCompile(`([A-Za-z0-9_]+)\s*:`).FindAllStringSubmatch(n[1], -1) {
|
||||||
|
if !settings[m[1]] {
|
||||||
|
t.Errorf("numberFields 里的 %q 不是设置项", m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存设置必须把新增字段真的落到 config 上:漏一个赋值就是「界面显示已保存、实际没生效」,
|
||||||
|
// 3 秒后刷新还被刷回原值,页面上完全看不出问题
|
||||||
|
func TestWebConfigHandlerSavesMergeFpsAndNotifyMergeFail(t *testing.T) {
|
||||||
|
dir := setupMergeTest(t)
|
||||||
|
savedConfig := config
|
||||||
|
savedLocation := configFileLocation
|
||||||
|
oldDir := configDir
|
||||||
|
if configDir == nil {
|
||||||
|
configDir = new(string)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
config = savedConfig
|
||||||
|
configFileLocation = savedLocation
|
||||||
|
configDir = oldDir
|
||||||
|
}()
|
||||||
|
*configDir = dir
|
||||||
|
configFileLocation = filepath.Join(dir, configFile)
|
||||||
|
|
||||||
|
post := func(body map[string]any) string {
|
||||||
|
t.Helper()
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(data))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
webConfigHandler(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("保存设置被拒(%d):%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
return rec.Body.String()
|
||||||
|
}
|
||||||
|
base := func() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"directory": dir,
|
||||||
|
"ffmpegPath": testFFmpeg(t),
|
||||||
|
"recordSubdir": defaultRecordSubdir,
|
||||||
|
"mergeSubdir": defaultMergeSubdir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.MergeFps = 0
|
||||||
|
config.NotifyMergeFail = true
|
||||||
|
b := base()
|
||||||
|
b["mergeFps"] = 60
|
||||||
|
b["notifyMergeFail"] = false
|
||||||
|
post(b)
|
||||||
|
if config.MergeFps != 60 {
|
||||||
|
t.Errorf("mergeFps 没被保存下来:%d", config.MergeFps)
|
||||||
|
}
|
||||||
|
if config.NotifyMergeFail {
|
||||||
|
t.Error("notifyMergeFail=false 没被保存下来")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超范围(负数或大得离谱)必须挡在保存之前
|
||||||
|
for _, bad := range []int{-1, maxMergeFps + 1} {
|
||||||
|
config.MergeFps = 7
|
||||||
|
b = base()
|
||||||
|
b["mergeFps"] = bad
|
||||||
|
data, _ := json.Marshal(b)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(data))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
webConfigHandler(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("mergeFps=%d 应当被拒(400),实际 %d", bad, rec.Code)
|
||||||
|
}
|
||||||
|
if config.MergeFps != 7 {
|
||||||
|
t.Errorf("mergeFps=%d 被拒后不该改到配置里,实际 %d", bad, config.MergeFps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user