Compare commits

...
5 Commits
Author SHA1 Message Date
tamakyi 9c94a72c4d 合并前重排弹道并支持自定义弹幕字号
用户反馈两件事:弹幕想再小些;某场合并的 1:19:25 处弹幕糊在一起(把成片抽帧
出来一看,是同一弹道上两条弹幕追尾叠字)。

重叠的根因:弹幕从上游原版的 .ass 还原成 jsonl 时,lane 是按「只有内容」的
宽度反推的(原格式的 x/y 就是这么摆的),而渲染用的是「昵称(uid):内容 + 头像」
的完整宽度 —— 后者占位时间长得多,照搬原弹道必然追尾。用用户那份 3011 条的真实
jsonl 验过:按渲染公式统计,重排前 960 条(31.9%)追尾,重排后 0 条。

- 新增 relayoutLanes:渲染前按最终字号统一重排弹道并重算宽度,公式和录制时的
  placeLane 完全一致,所以录制下来的 jsonl 重排后弹道不变(已加用例覆盖)。
- 空弹道用 math.MinInt64 占位:placeLane 的条件是「本条出现时间晚于上一条的让位
  时刻」,零值下 0 > 0 不成立,第一条弹幕(还原出来的时间可能正好是 0)会占不上
  弹道、占用状态丢失,后面又跟着乱排。录制的弹幕时间总大于 0,所以原逻辑没暴露。
- 弹道全满(100 条都占着)时退到最早让出来的那条:录制时是直接不写这条,合并时
  丢一条等于凭空少一句弹幕,压一点也比丢了强。
- 新增 danmuFontSize 设置(合并分区,0 = 跟随弹幕文件):覆盖渲染字号,头像按
  同一比例缩放;改了字号会在重排时一并生效,不会因为宽度变了又追尾。

验证:layout_test.go 5 个用例;用新 ffmpeg(用户已自行升级到 n9.0.2)在隔离实例上
跑了真合并 —— 字号 95 以上的对照(60px → 42px:抽帧量到的弹幕墨迹高度 48px → 36px)、
按 1:19:00 裁出的 130 秒片段合并前后追尾 25 条 → 0 条;重跑 verify_settings.py
(新增第 8 节覆盖字号字段)、verify_pick.py、verify_streamer_br.py 无回归;
go test ./...、gofmt、go vet 干净。
2026-09-25 18:18:30 +08:00
tamakyi e35a4f7cef 合并先在本地做完再落到成片目录;头像支持 gif 和静态 webp
起因是核查合并落盘和头像处理时发现的两处问题:

1) 成片落盘:ffmpeg 原来是直接写目标目录(automerge.go 里那句注释「不再事后再
   移动一次」)。成片目录可能是网络盘或外接盘,写到一半失败会在那里留下一个
   看起来正常的半成品。现在改成先本地合成、完成后再落盘:
   - 输出先写到弹幕文件所在目录(本地)的 acfunlive_chunk_merging_*
   - 写完后由 placeOutput 落盘:同盘直接 os.Rename(瞬时),跨盘退回拷贝;
     拷贝中途失败留下的是 *.acfunlive.part,一眼看得出不是成品
   - 成功或失败都会清掉本地临时文件,失败时两个目录都不留东西
   - 临时文件沿用 chunkTempPrefix,不再出现在文件管理的列表里(listFiles 过滤)
   - 启动时清理上次残留;删不掉时提示一句 —— 强杀程序不会连带杀掉 ffmpeg
     子进程,它可能还在写这个文件,这种情况下次启动会再清一次

2) 头像:downloadAvatar 只注册了 jpeg 和 png 解码器,AcFun 上的 gif 头像必然
   解码失败,静态 webp 同样。加上 _ "image/gif"(多帧取第一帧)和
   _ "golang.org/x/image/webp"(x/image 本来就是直接依赖,零新增模块)。
   动画 webp 两条路都解不开(x/image 报 invalid format,ffmpeg 5.0 也不行),
   保持只烧文字。解码失败时错误信息带上 Content-Type,方便排查是哪张图。

在隔离实例上用真 ffmpeg 跑了真合并验证:gif 头像和 webp 头像都进了缓存并烧进
画面(抽帧确认圆形头像);日志里 ffmpeg 的输出是 record 下的临时文件,完成后
才出现在 merged;坏视频合成失败后成片目录没有半成品、录播目录零残留;合并
中途强杀留下的临时文件在下次启动时被清掉。

新增 output_test.go(7 个)和 avatar_test.go(3 个);go test ./...、gofmt、
go vet 干净。
2026-09-25 17:34:31 +08:00
tamakyi eb250bb842 web UI:手动合并的视频文件也能点选,一次可选多个
之前只有弹幕框挂了「浏览…」,视频框只能手填,或者用那个原生多选下拉
(要按 Ctrl、点一下就把手填的内容整段覆盖、手机上根本没法用)。原因是
视频框是「一行一个、可能多个」的形态,而浏览弹窗当时只支持单选。

改成同一个弹窗支持多选:

- 「浏览…」多带一个多选开关,视频框用它打开;列表里点文件名即勾选、
  再点取消,条目左边有勾选框,底部显示「已选 N 项」并能一键清空。
  勾选状态由页面保管、换目录不清,所以可以进别的文件夹继续勾、跨盘攒。
- 选用是**追加**到框里:手填的行和之前选好的行都留着,重复的不加第二遍。
  双击某个文件=把这一个一起追加进去并关窗(和单选的「双击即选用」一致)。
- 填入时录播目录下的文件只写文件名(跟着保存目录走),别处的写绝对路径;
  单选的弹幕框也按同一规则,顺手让框里干净些。
- 多选框取第一行当起点,不再把整段多行文本当路径去解析。

顺带修一个连带问题:手动合并的视频/弹幕框不进配置,refresh 也不会回填它们,
之前选完却会打上「未保存」标记 —— 顶栏那句「有未保存的修改」永远亮着,
点保存也清不掉。现在只对设置表单里的框打标记。

verify_pick.py 扩到 48 项断言(勾选/取消/跨目录累积/清空/追加不覆盖/填文件名
还是绝对路径/手机端点选/配置全程没动)全部通过;verify_settings.py 与
verify_streamer_br.py 重跑无回归;go test ./...、gofmt、go vet 干净。
2026-09-25 16:43:21 +08:00
tamakyi 7a396a8852 web UI:目录和文件既能点「浏览」逐级选,也能手工填
浏览器拿不到本地绝对路径,所以「交互式选择」做成后端列目录 + 弹窗点选:

- 新增 GET /api/browse:path 为空时用程序所在目录,kind=file 连文件一起列,
  hidden=1 带上点开头的隐藏项;文件夹优先排序,上限 2000 项。打不开时仍返回
  200 并带上 error 和 parent —— 页面据此自动向上退,不会把用户卡在一个打不开
  的路径里(框里填的是文件路径时,就是这样落到它所在的目录的)。权限没放宽,
  和其它管理接口一样走口令。
- 文件保存目录、成片移动到、ffmpeg 路径、手动合并的弹幕文件这四个框加了
  「浏览…」按钮,输入框本身照旧可以直接填或粘贴;弹窗里的地址栏也能粘贴路径
  回车跳转,点选和手填随时切换。默认不列隐藏项,需要时勾「显示隐藏项」。
- 文件夹单击进入(手机上也能用),文件单击选中、双击直接选用;连点同一项、
  以及刚跳转过后的 350ms 内忽略点击,双击文件夹不会连进两层。
- 选完只标记「未保存」并填进输入框,不自动保存,和文本框的规则保持一致。
- 补上 README 里的用法和 doc/webapi.md 里的接口说明。

新增 browse_test.go(12 个用例);仓库外的 verify_pick.py 30 项断言全过,
go test ./...、gofmt、go vet、构建均干净。
2026-09-25 16:30:01 +08:00
tamakyi 0dfa224bac web UI:没提交的文本框不再被状态刷新刷回原值
页面每 3 秒拉一次 /api/status 回填表单,而回填只跳过「正在聚焦」的元素,
所以文本框改完点一下别处(或按回车,输入框不在 form 里根本不会提交),
几秒后就被服务端的旧值盖回去,看起来像「改了又自己恢复」——
改合成码率时的现象就是输入框里明明是 6500、过了 3 秒自己变回 1500。
服务端日志里一条 6500 的保存记录都没有,全是前端盖的。

- 表单字段加上「未提交」标记,定时刷新不再覆盖;提交成功才清掉
- 回车等于点保存设置(主播码率框回车即提交,新增 uid 回车即添加)
- 有没提交的内容时顶栏提示「有未保存的修改」,保存按钮右上角标红点,
  离开页面前再问一次
- 保存失败保留用户填的内容,不再静默回滚,提示里带上服务端给的原因
- 开关、下拉的立即保存用服务端值填还没提交的字段,避免一个框填错
  (如成片目录不存在)把开关也一起打回去
- 主播列表里的码率框:重建列表时保留用户改过还没提交的值,填错立刻
  回填服务端值
2026-09-25 15:50:34 +08:00
14 changed files with 1406 additions and 59 deletions
+13 -2
View File
@@ -81,6 +81,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
"mergeDelDanmu": false, // 合并完成后删除弹幕文件(ass、nick.ass、jsonl) "mergeDelDanmu": false, // 合并完成后删除弹幕文件(ass、nick.ass、jsonl)
"mergeOutputDir": "", // 合并完成后成片移动到该文件夹,留空则不移动 "mergeOutputDir": "", // 合并完成后成片移动到该文件夹,留空则不移动
"mergeBitrate": 0, // 合并成片的目标码率(Kbps),0 表示使用默认质量 "mergeBitrate": 0, // 合并成片的目标码率(Kbps),0 表示使用默认质量
"danmuFontSize": 0, // 合并时弹幕文字的字号(像素),0 表示跟随弹幕文件;头像随字号等比缩放
"defaultBitrate": 0, // 新添加主播的默认 bitrate,0 表示码率最高的直播源 "defaultBitrate": 0, // 新添加主播的默认 bitrate,0 表示码率最高的直播源
"webExternal": false, // 是否允许外网访问 web UI,false 时只监听本机 127.0.0.1 "webExternal": false, // 是否允许外网访问 web UI,false 时只监听本机 127.0.0.1
@@ -114,6 +115,8 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
录播原片和弹幕文件(`.ass`、`.nick.ass`、`.jsonl`)保存在保存目录下的`recordSubdir`子文件夹里(默认`record`),两者同名配对才能自动合并;合并后的成片保存在`mergeSubdir`子文件夹里(默认`merged`),留空则用默认值,两个名字不能相同。如果设置了`mergeOutputDir`,成片优先移动到那里。 录播原片和弹幕文件(`.ass`、`.nick.ass`、`.jsonl`)保存在保存目录下的`recordSubdir`子文件夹里(默认`record`),两者同名配对才能自动合并;合并后的成片保存在`mergeSubdir`子文件夹里(默认`merged`),留空则用默认值,两个名字不能相同。如果设置了`mergeOutputDir`,成片优先移动到那里。
合成**先在录播文件夹里做完,写完了才落到成片文件夹**(同一个盘时是直接改名,几乎瞬时;跨盘会自动拷贝):成片文件夹是网络盘、外接盘或者中途断线时,不会在里面留下一个写到一半、看起来却像正常成片的文件。合成失败或中途强杀程序时,临时文件(`acfunlive_chunk_*`、`*.acfunlive.part`)不会出现在文件管理的列表里,程序下次启动会自动清掉。
`recordFormat`决定录播原片的封装格式,默认为`mp4`(和原版`config.Output`的默认值一致),也可以改成`flv`、`ts`、`mkv`,在 web UI 的`录制`分区用下拉框修改。录制始终是`-c copy`直接封装直播源、不重新编码,所以四种格式的**画质和码率完全一样,区别只在容器**。 `recordFormat`决定录播原片的封装格式,默认为`mp4`(和原版`config.Output`的默认值一致),也可以改成`flv`、`ts`、`mkv`,在 web UI 的`录制`分区用下拉框修改。录制始终是`-c copy`直接封装直播源、不重新编码,所以四种格式的**画质和码率完全一样,区别只在容器**。
正常停止(web UI 的停止按钮、`stoprecord`、`Ctrl+C`、`quit`)会先向 ffmpeg 发`q`让它正常收尾,**四种格式都能正常播放**——本机 ffmpeg 6.0 实测录 5 秒后发`q`:mp4 161 KB、flv 163 KB、ts 191 KB、mkv 160 KB,全部可读且时长 5.14 秒。但如果进程被强杀(任务管理器结束进程、断电、系统崩溃),差别就很大了——同样录制 8 秒后强杀: 正常停止(web UI 的停止按钮、`stoprecord`、`Ctrl+C`、`quit`)会先向 ffmpeg 发`q`让它正常收尾,**四种格式都能正常播放**——本机 ffmpeg 6.0 实测录 5 秒后发`q`:mp4 161 KB、flv 163 KB、ts 191 KB、mkv 160 KB,全部可读且时长 5.14 秒。但如果进程被强杀(任务管理器结束进程、断电、系统崩溃),差别就很大了——同样录制 8 秒后强杀:
@@ -156,7 +159,13 @@ web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`),
| 文件 | 磁盘剩余容量、录播和成片的文件列表 | | 文件 | 磁盘剩余容量、录播和成片的文件列表 |
| 设置 | 是否允许外网访问、管理口令的查看与更换 | | 设置 | 是否允许外网访问、管理口令的查看与更换 |
开关和下拉框改完立即保存;文本框(目录、地址、口令这些)填完点右上角的`保存设置`才生效,这一条在每个有文本框的分区里也写了提示。 开关和下拉框改完立即保存;文本框(目录、地址、口令这些)填完按回车或点右上角的`保存设置`才生效,这一条在每个有文本框的分区里也写了提示。
目录和文件两条路都行:直接在框里填,或者点框右边的`浏览`逐级点选。弹窗里可以一路点进子文件夹、点`C:`/`D:`这类按钮(Linux 上是`/`、`/home`、`/mnt` 这些常用位置)换根位置,地址栏本身也能粘贴路径回车跳转,随时在点选和手填之间切换;选好点`选用这个文件夹`填回设置框,仍然是按回车或点保存才生效。填的是文件时(比如 ffmpeg 路径)列出的是文件,双击即选中并填入。默认不显示以点开头的隐藏项,需要时勾上`显示隐藏项`。
要选的是一组文件时(`手动合并`里的视频框,一行一个)用的是同一套弹窗,区别只是能多选:点一下文件名即勾选、再点一下取消(手机上也一样点,不用按 Ctrl),可以换到别的文件夹继续勾,底部显示`已选 N 项`、点`清空`能重来,双击某个文件则是「把这一个一起追加进去并关窗」。选完点`选用这 N 个文件`是**追加**到框里,手填的内容和其他已经选好的行都不会被清掉,重复的不会加两遍。填入时**录播目录下的文件只写文件名**(跟着保存目录走,以后改了保存目录也还认得),别的文件夹里的文件写绝对路径。这个视频框和旁边的弹幕框都只服务于一次手动合并,不属于设置项,所以选完不会有`有未保存的修改`提示,也不用去点`保存设置`,直接点`开始合并`即可。
页面每 3 秒从服务端取一次状态回填表单,但**不会覆盖你改过还没提交的字段**,所以填好的数字不会过几秒又被刷回旧值;有没提交的内容时顶栏会显示`有未保存的修改`(保存按钮右上角也有个红点),此时离开或刷新页面浏览器会再问一次。保存失败(比如成片目录填了个不存在的路径)时提示里会带上原因,并且**输入框里填的内容会保留**,改对了再存即可;开关和下拉框的立即保存不会带上前述还没提交的文本框内容,所以某个框填错也不会连累开关保存失败。
顶栏右上角一直显示当前状态(`录制中 N` / `直播中 N` / `空闲`)。窄屏上按钮收起成短文案、标题居中,手机上也能正常用;页面跟随系统的深浅色设置,深色模式下日志和表单自动变深色,不用额外配置。 顶栏右上角一直显示当前状态(`录制中 N` / `直播中 N` / `空闲`)。窄屏上按钮收起成短文案、标题居中,手机上也能正常用;页面跟随系统的深浅色设置,深色模式下日志和表单自动变深色,不用额外配置。
@@ -191,7 +200,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`里,圆形裁切后的直径与弹幕文字的墨迹高度一致。上游原版的`.ass`里没有头像链接(`nick.ass`只有昵称和 uid),遇到这种情况会按 uid 向 AcFun 的`userInfo`接口反查头像,查不到的只烧文字。反查走网络且有超时,所以离线环境下合并会退化成纯文字弹幕并给出警告。 头像会按 uid 缓存到录制文件夹下的`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`。
@@ -205,6 +214,8 @@ acfunlive -danmuburn xxx.jsonl -video xxx.flv -ffmpeg D:\tools\ffmpeg.exe
手动停止录制(web UI 的`停止`按钮或命令行里停止)不会触发自动合并,重新开始录制后新的一组文件仍会在直播结束时自动合并。这一场想补出成片的话,用下面的`手动合并`。合并需要 ffmpeg,且会占用 CPU 或显卡资源,长录播的合并耗时较长。 手动停止录制(web UI 的`停止`按钮或命令行里停止)不会触发自动合并,重新开始录制后新的一组文件仍会在直播结束时自动合并。这一场想补出成片的话,用下面的`手动合并`。合并需要 ffmpeg,且会占用 CPU 或显卡资源,长录播的合并耗时较长。
弹幕字号默认沿用弹幕文件里记的字号(1080p 下通常是 48~60),嫌大可以调`弹幕字号`,头像会跟着等比缩小。合并前会按最终字号把弹幕重新排一遍弹道:从上游原版的`.ass`还原出来的弹幕,原本是按「只有内容」的宽度占位的,而渲染用的是「昵称(uid):内容 + 头像」的宽度,占位更久,照搬原弹道会追尾叠字——重排按完整宽度重新分配,同一条弹道上不会再有两条弹幕叠在一起。
停止录播时会向 ffmpeg 发送正常退出的指令,让它把文件正常收尾(直接杀进程会在文件尾部留下损坏的数据包,mp4 甚至会因为缺少 moov atom 而整个读不出来)。 停止录播时会向 ffmpeg 发送正常退出的指令,让它把文件正常收尾(直接杀进程会在文件尾部留下损坏的数据包,mp4 甚至会因为缺少 moov atom 而整个读不出来)。
`autoMerge`、`mergeDelVideo`、`mergeDelDanmu`三项互不影响,可以同时开启:第一项决定是否自动合并,后两项是合并成功后的清理动作。 `autoMerge`、`mergeDelVideo`、`mergeDelDanmu`三项互不影响,可以同时开启:第一项决定是否自动合并,后两项是合并成功后的清理动作。
+12 -4
View File
@@ -252,19 +252,22 @@ func autoMerge(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int
return return
} }
// 成片直接输出到成片文件夹,不再事后再移动一次 // 成片先在本地合成,再落到成片文件夹:成片目录可能是网络盘或外接盘,
// 直接往里写、写到一半失败会在那里留下一个看起来正常的半成品
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
} }
outFile := filepath.Join(mergeDir, filepath.Base(strings.TrimSuffix(jsonlFile, ".jsonl")+"_弹幕合并.mp4")) outFile := filepath.Join(mergeDir, filepath.Base(strings.TrimSuffix(jsonlFile, ".jsonl")+"_弹幕合并.mp4"))
tmpOut := tempMergeOutput(jsonlFile, filepath.Base(outFile))
// 标记成正在写入,避免在 web UI 的文件管理里把还没写完的成片删掉 // 标记成正在写入,避免在 web UI 的文件管理里把还没写完的成片删掉
markFileActive(outFile, true) markFileActive(tmpOut, true)
burnErr := burnDanmu(jsonl, video, outFile, offset) burnErr := burnDanmu(jsonl, video, tmpOut, offset)
markFileActive(outFile, false) markFileActive(tmpOut, false)
if burnErr != nil { if burnErr != nil {
_ = os.Remove(tmpOut)
if burnErr == errNoDanmu { if burnErr == errNoDanmu {
lPrintWarn("弹幕文件里没有弹幕,跳过自动合并") lPrintWarn("弹幕文件里没有弹幕,跳过自动合并")
return return
@@ -272,6 +275,11 @@ func autoMerge(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int
lPrintErrf("自动合并失败:%v", burnErr) lPrintErrf("自动合并失败:%v", burnErr)
return return
} }
if err := placeOutput(tmpOut, outFile); err != nil {
_ = os.Remove(tmpOut)
lPrintErrf("自动合并失败:%v", err)
return
}
lPrintln("自动合并完成:" + outFile) lPrintln("自动合并完成:" + outFile)
sendNotify(notifyEventMergeDone, uid, nameForLog(uid), "", "", outFile, sendNotify(notifyEventMergeDone, uid, nameForLog(uid), "", "", outFile,
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"bytes"
"encoding/base64"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"testing"
)
// 2x2 的静态 webp(纯红),用来确认 webp 解码器确实注册上了
const tinyWebPBase64 = "UklGRjwAAABXRUJQVlA4IDAAAADQAQCdASoCAAIAAgA0JaACdLoB+AADsAD+8Oj3/yC5YXXI1/8gP+QH/ID/+PIAAAA="
// 多帧 gif 解出来应该是第一帧(AcFun 上有动图头像,压成静态就用第一帧)
func TestDecodeAvatarGifTakesFirstFrame(t *testing.T) {
pal := color.Palette{color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255}}
first := image.NewPaletted(image.Rect(0, 0, 8, 8), pal)
second := image.NewPaletted(image.Rect(0, 0, 8, 8), pal)
for i := range second.Pix {
second.Pix[i] = 1
}
var buf bytes.Buffer
if err := gif.EncodeAll(&buf, &gif.GIF{
Image: []*image.Paletted{first, second},
Delay: []int{10, 10},
}); err != nil {
t.Fatal(err)
}
src, err := decodeAvatar(buf.Bytes(), "image/gif")
if err != nil {
t.Fatalf("gif 解码失败:%v", err)
}
r, g, b, _ := src.At(4, 4).RGBA()
if r>>8 != 255 || g>>8 != 0 || b>>8 != 0 {
t.Errorf("应该取第一帧(红),实际拿到 r=%d g=%d b=%d", r>>8, g>>8, b>>8)
}
}
// jpg / png / 静态 webp 都能解,解不出来的要给中文原因
func TestDecodeAvatarFormats(t *testing.T) {
var pngBuf, jpgBuf bytes.Buffer
img := image.NewRGBA(image.Rect(0, 0, 8, 8))
if err := png.Encode(&pngBuf, img); err != nil {
t.Fatal(err)
}
if err := jpeg.Encode(&jpgBuf, img, nil); err != nil {
t.Fatal(err)
}
webpData, err := base64.StdEncoding.DecodeString(tinyWebPBase64)
if err != nil {
t.Fatal(err)
}
cases := []struct {
name string
data []byte
contentType string
wantErr bool
}{
{"png", pngBuf.Bytes(), "image/png", false},
{"jpeg", jpgBuf.Bytes(), "image/jpeg", false},
{"webp", webpData, "image/webp", false},
{"不是图片", []byte("这不是图片"), "text/html", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
src, err := decodeAvatar(c.data, c.contentType)
if c.wantErr {
if err == nil {
t.Fatal("应该报错")
}
if !bytes.Contains([]byte(err.Error()), []byte(c.contentType)) {
t.Errorf("错误信息里应该带上 Content-Type 方便排查:%v", err)
}
return
}
if err != nil {
t.Fatalf("解码失败:%v", err)
}
if src == nil {
t.Fatal("解出来是空的")
}
})
}
}
// 解出来的头像要能走通圆形裁切 + 缓存用的 PNG 编码(gif 是 *image.Paletted,和 jpg/png 的底层类型都不同)
func TestDecodeAvatarGifGoesThroughCircleAvatar(t *testing.T) {
pal := color.Palette{color.RGBA{G: 255, A: 255}, color.RGBA{R: 255, B: 255, A: 255}}
frame := image.NewPaletted(image.Rect(0, 0, 16, 16), pal)
var buf bytes.Buffer
if err := gif.EncodeAll(&buf, &gif.GIF{Image: []*image.Paletted{frame}, Delay: []int{10}}); err != nil {
t.Fatal(err)
}
src, err := decodeAvatar(buf.Bytes(), "image/gif")
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
if err := png.Encode(&out, circleAvatar(src, avatarCacheSize)); err != nil {
t.Fatalf("圆形裁切后的 PNG 编码失败:%v", err)
}
if out.Len() == 0 {
t.Fatal("编码出来是空的")
}
}
+164
View File
@@ -0,0 +1,164 @@
// web UI 的路径选择器:给设置里的目录 / 文件框提供「浏览」
package main
import (
"encoding/json"
"io/fs"
"net/http"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
)
// 选择器里的一个条目
type browseEntry struct {
Name string `json:"name"`
Path string `json:"path"` // 完整路径,直接回填到设置里
Dir bool `json:"dir"`
Size int64 `json:"size"`
}
// 展开目录的结果
type browseResp struct {
Path string `json:"path"` // 当前展开的目录,空表示只给根列表
Parent string `json:"parent"` // 上级目录,已经到根时为空
Roots []browseEntry `json:"roots"` // 盘符 / 常用挂载点
Entries []browseEntry `json:"entries"` // 子文件夹(选文件时还含文件)
Truncated bool `json:"truncated"` // 条目太多只返回了一部分
Error string `json:"error"` // 展开失败的原因
}
// 单个目录最多返回多少项,避免几万个文件的目录把页面卡住
const browseMaxEntries = 2000
// 以点开头的当成隐藏项(Linux 下的 .config,Windows 下各类工具留下的 .cache 之类)
func browseHidden(name string) bool {
return strings.HasPrefix(name, ".")
}
// 上级目录,已经是根(盘符或 /)时返回空
func browseParent(abs string) string {
parent := filepath.Dir(abs)
if parent == abs {
return ""
}
return parent
}
// 列出 path 下的内容,wantFiles 为真时连文件一起列(选文件用)
// truncated 表示条目太多被截断了
func browseList(path string, wantFiles, showHidden bool) (entries []browseEntry, parent string, truncated bool, err error) {
abs, err := filepath.Abs(path)
if err != nil {
return nil, "", false, err
}
parent = browseParent(abs)
items, err := os.ReadDir(abs)
if err != nil {
return nil, parent, false, err
}
out := make([]browseEntry, 0, len(items))
for _, item := range items {
name := item.Name()
if !showHidden && browseHidden(name) {
continue
}
full := filepath.Join(abs, name)
isDir := item.IsDir()
// ReadDir 用的是 lstat,指向文件夹的符号链接会被当成文件,这里补一次判断
if !isDir && item.Type()&fs.ModeSymlink != 0 {
if info, err := os.Stat(full); err == nil {
isDir = info.IsDir()
}
}
if isDir {
out = append(out, browseEntry{Name: name, Path: full, Dir: true})
continue
}
if !wantFiles {
continue
}
info, err := item.Info()
if err != nil {
continue
}
out = append(out, browseEntry{Name: name, Path: full, Size: info.Size()})
}
// 文件夹排前面,同类型按名字排(Windows 上的大小写不敏感)
sort.SliceStable(out, func(i, j int) bool {
if out[i].Dir != out[j].Dir {
return out[i].Dir
}
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
})
if len(out) > browseMaxEntries {
out, truncated = out[:browseMaxEntries], true
}
if out == nil {
out = []browseEntry{}
}
return out, parent, truncated, nil
}
// 根位置:Windows 下列盘符,其它平台给几个常用的挂载点
func browseRoots() []browseEntry {
out := make([]browseEntry, 0, 10)
if runtime.GOOS == "windows" {
for c := 'A'; c <= 'Z'; c++ {
drive := string(c) + ":\\"
if info, err := os.Stat(drive); err == nil && info.IsDir() {
out = append(out, browseEntry{Name: string(c) + ":", Path: drive, Dir: true})
}
}
return out
}
for _, dir := range []string{"/", "/home", "/mnt", "/media", "/srv", "/data", "/opt", "/var/lib", "/tmp"} {
if info, err := os.Stat(dir); err == nil && info.IsDir() {
out = append(out, browseEntry{Name: dir, Path: dir, Dir: true})
}
}
if home, err := os.UserHomeDir(); err == nil {
if info, err := os.Stat(home); err == nil && info.IsDir() {
out = append(out, browseEntry{Name: "主目录 " + home, Path: home, Dir: true})
}
}
return out
}
// 展开目录,供页面上的「浏览」按钮用
// kind=file 时连文件一起列,hidden=1 时显示隐藏项
func browseHandler(w http.ResponseWriter, r *http.Request) {
resp := browseResp{
Roots: browseRoots(),
Entries: []browseEntry{},
}
target := strings.TrimSpace(r.URL.Query().Get("path"))
if target == "" {
// 没给路径时从程序所在目录开始,多半就是用户想选的地方
if wd, err := os.Getwd(); err == nil {
target = wd
}
}
if target != "" {
abs, err := filepath.Abs(target)
if err != nil {
resp.Path = target
resp.Error = "路径不合法:" + err.Error()
} else {
resp.Path = abs
entries, parent, truncated, err := browseList(abs, r.URL.Query().Get("kind") == "file", r.URL.Query().Get("hidden") == "1")
resp.Entries, resp.Parent, resp.Truncated = entries, parent, truncated
if err != nil {
resp.Error = "打开失败:" + err.Error()
}
}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(resp); err != nil {
lPrintErrf("输出目录列表失败:%v", err)
}
}
+254
View File
@@ -0,0 +1,254 @@
// 路径选择器相关
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"runtime"
"testing"
)
// 造一棵目录树:两个名字大小写不同的文件夹、两个文件、一个隐藏文件夹
func setupBrowseTree(t *testing.T) string {
t.Helper()
dir := t.TempDir()
for _, sub := range []string{"beta", "Alpha", ".hidden"} {
if err := os.MkdirAll(filepath.Join(dir, sub), 0755); err != nil {
t.Fatal(err)
}
}
for _, f := range []string{"b.txt", "a.mp4"} {
if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
}
return dir
}
func browseNames(entries []browseEntry) []string {
out := make([]string, 0, len(entries))
for _, e := range entries {
out = append(out, e.Name)
}
return out
}
func browseHas(entries []browseEntry, name string) bool {
for _, e := range entries {
if e.Name == name {
return true
}
}
return false
}
func doBrowseQuery(t *testing.T, v url.Values) browseResp {
t.Helper()
q := ""
if v != nil {
q = "?" + v.Encode()
}
rec := httptest.NewRecorder()
browseHandler(rec, httptest.NewRequest(http.MethodGet, "/api/browse"+q, nil))
if rec.Code != http.StatusOK {
t.Fatalf("状态码是 %d,想要 200", rec.Code)
}
var resp browseResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("解析响应失败:%v(%s)", err, rec.Body.String())
}
return resp
}
func doBrowse(t *testing.T, path, kind string, hidden bool) browseResp {
t.Helper()
v := url.Values{}
if path != "" {
v.Set("path", path)
}
if kind != "" {
v.Set("kind", kind)
}
if hidden {
v.Set("hidden", "1")
}
return doBrowseQuery(t, v)
}
func TestBrowseListDirsOnly(t *testing.T) {
dir := setupBrowseTree(t)
entries, parent, truncated, err := browseList(dir, false, false)
if err != nil {
t.Fatalf("列目录失败:%v", err)
}
if truncated {
t.Fatal("条目没超上限,不该标成截断")
}
if parent != filepath.Dir(dir) {
t.Fatalf("上级目录不对:%q", parent)
}
want := []string{"Alpha", "beta"}
if len(entries) != len(want) {
t.Fatalf("只该列出文件夹:%q", browseNames(entries))
}
for i, e := range entries {
if e.Name != want[i] {
t.Fatalf("第 %d 项是 %q,想要 %q(文件夹排前面、按名字不区分大小写排)", i, e.Name, want[i])
}
if !e.Dir || e.Path != filepath.Join(dir, e.Name) {
t.Fatalf("条目内容不对:%+v", e)
}
}
}
func TestBrowseListWithFiles(t *testing.T) {
dir := setupBrowseTree(t)
entries, _, _, err := browseList(dir, true, false)
if err != nil {
t.Fatalf("列目录失败:%v", err)
}
want := []string{"Alpha", "beta", "a.mp4", "b.txt"}
if len(entries) != len(want) {
t.Fatalf("条目不对:%q", browseNames(entries))
}
for i, e := range entries {
if e.Name != want[i] {
t.Fatalf("第 %d 项是 %q,想要 %q", i, e.Name, want[i])
}
}
// 选文件时文件夹也要列出来,不然没法点进去
if !entries[0].Dir {
t.Fatal("选文件时也该列出文件夹")
}
if entries[2].Dir || entries[2].Size != 1 {
t.Fatalf("文件条目不对:%+v", entries[2])
}
}
func TestBrowseListHidden(t *testing.T) {
dir := setupBrowseTree(t)
entries, _, _, err := browseList(dir, false, false)
if err != nil {
t.Fatalf("列目录失败:%v", err)
}
if browseHas(entries, ".hidden") {
t.Fatal("默认不该列出隐藏文件夹")
}
entries, _, _, err = browseList(dir, false, true)
if err != nil {
t.Fatalf("列目录失败:%v", err)
}
if !browseHas(entries, ".hidden") {
t.Fatalf("开了显示隐藏项就该列出来:%q", browseNames(entries))
}
}
func TestBrowseParentAtRoot(t *testing.T) {
root := "/"
if runtime.GOOS == "windows" {
root = `C:\`
}
if got := browseParent(root); got != "" {
t.Fatalf("根位置不该有上级:%q", got)
}
if got := browseParent(filepath.Join(root, "a")); got != root {
t.Fatalf("子目录的上级应该是根:%q", got)
}
}
func TestBrowseRootsNotEmpty(t *testing.T) {
if len(browseRoots()) == 0 {
t.Fatal("根列表不该是空的,否则用户没法从盘符开始选")
}
}
func TestBrowseHandlerDir(t *testing.T) {
dir := setupBrowseTree(t)
resp := doBrowse(t, dir, "dir", false)
if resp.Error != "" {
t.Fatalf("不该报错:%s", resp.Error)
}
if resp.Path != dir {
t.Fatalf("回显的路径不对:%q", resp.Path)
}
if resp.Parent != filepath.Dir(dir) {
t.Fatalf("上级目录不对:%q", resp.Parent)
}
if len(resp.Roots) == 0 {
t.Fatal("根列表不该为空")
}
if got := browseNames(resp.Entries); len(got) != 2 || got[0] != "Alpha" || got[1] != "beta" {
t.Fatalf("条目不对:%q", got)
}
}
func TestBrowseHandlerFileKind(t *testing.T) {
dir := setupBrowseTree(t)
resp := doBrowse(t, dir, "file", false)
if resp.Error != "" {
t.Fatalf("不该报错:%s", resp.Error)
}
if !browseHas(resp.Entries, "a.mp4") || !browseHas(resp.Entries, "b.txt") {
t.Fatalf("选文件时该列出文件:%q", browseNames(resp.Entries))
}
for _, e := range resp.Entries {
if e.Name == "a.mp4" {
if e.Dir {
t.Fatal("文件不该被标成文件夹")
}
if e.Path != filepath.Join(dir, "a.mp4") {
t.Fatalf("文件路径不对:%q", e.Path)
}
}
}
}
func TestBrowseHandlerMissingPath(t *testing.T) {
dir := setupBrowseTree(t)
resp := doBrowse(t, filepath.Join(dir, "没有这个目录"), "dir", false)
if resp.Error == "" {
t.Fatal("不存在的目录要给出错误提示,而不是静默返回空列表")
}
// 打不开的时候仍要能往上走,不然用户就卡在一个不存在的路径里了
if resp.Parent != dir {
t.Fatalf("错误时也该带上上级目录:%q", resp.Parent)
}
if len(resp.Entries) != 0 {
t.Fatalf("打不开的目录不该有内容:%q", browseNames(resp.Entries))
}
}
func TestBrowseHandlerPathIsFile(t *testing.T) {
dir := setupBrowseTree(t)
resp := doBrowse(t, filepath.Join(dir, "a.mp4"), "dir", false)
if resp.Error == "" {
t.Fatal("把文件当目录展开应该报错")
}
}
func TestBrowseHandlerEmptyPathUsesWorkDir(t *testing.T) {
resp := doBrowse(t, "", "dir", false)
wd, err := os.Getwd()
if err != nil {
t.Skip("取不到工作目录")
}
if normalizePath(resp.Path) != normalizePath(wd) {
t.Fatalf("没给路径时该从程序所在目录开始:%q", resp.Path)
}
// 程序目录里至少有自己的源码,不该是空的
if len(resp.Entries) == 0 {
t.Fatal("程序所在目录不该是空的")
}
}
+8
View File
@@ -82,6 +82,7 @@ type configData struct {
MergeDelDanmu bool `json:"mergeDelDanmu"` // 合并完成后删除弹幕文件 MergeDelDanmu bool `json:"mergeDelDanmu"` // 合并完成后删除弹幕文件
MergeOutputDir string `json:"mergeOutputDir"` // 合并完成后成片移动到该文件夹,留空不移动 MergeOutputDir string `json:"mergeOutputDir"` // 合并完成后成片移动到该文件夹,留空不移动
MergeBitrate int `json:"mergeBitrate"` // 合并成片的目标码率(Kbps),0 表示使用默认质量 MergeBitrate int `json:"mergeBitrate"` // 合并成片的目标码率(Kbps),0 表示使用默认质量
DanmuFontSize int `json:"danmuFontSize"` // 合并时弹幕文字的字号(头像随字号等比缩放),0 表示跟随弹幕文件
DefaultBitrate int `json:"defaultBitrate"` // 新添加主播的默认下载码率上限(Kbps),0 表示录制码率最高的直播源 DefaultBitrate int `json:"defaultBitrate"` // 新添加主播的默认下载码率上限(Kbps),0 表示录制码率最高的直播源
@@ -360,6 +361,10 @@ func checkConfig() {
lPrintErrf("%s里的mergeBitrate不能小于 0", configFile) lPrintErrf("%s里的mergeBitrate不能小于 0", configFile)
os.Exit(1) os.Exit(1)
} }
if config.DanmuFontSize < 0 {
lPrintErrf("%s里的danmuFontSize不能小于 0(0 表示跟随弹幕文件)", configFile)
os.Exit(1)
}
if config.DefaultBitrate < 0 { if config.DefaultBitrate < 0 {
lPrintErrf("%s里的defaultBitrate不能小于 0", configFile) lPrintErrf("%s里的defaultBitrate不能小于 0", configFile)
os.Exit(1) os.Exit(1)
@@ -406,6 +411,9 @@ func checkConfig() {
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)
+75 -2
View File
@@ -11,6 +11,7 @@ import (
"image" "image"
"image/color" "image/color"
"image/draw" "image/draw"
_ "image/gif"
_ "image/jpeg" _ "image/jpeg"
"image/png" "image/png"
"io" "io"
@@ -20,12 +21,14 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
xdraw "golang.org/x/image/draw" xdraw "golang.org/x/image/draw"
_ "golang.org/x/image/webp"
) )
// 头像缓存目录名 // 头像缓存目录名
@@ -200,6 +203,14 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
} }
} }
// 弹幕字号可被设置覆盖(0 = 跟随弹幕文件),头像尺寸随字号等比缩放
if config.DanmuFontSize > 0 {
lPrintf("弹幕字号按设置使用 %d(弹幕文件里是 %d)", config.DanmuFontSize, meta.FontSize)
meta.FontSize = config.DanmuFontSize
}
// 弹道按最终字号统一重排,避免还原弹幕或改字号后同弹道追尾
relayoutLanes(records, meta)
// 下载并圆形裁切头像 // 下载并圆形裁切头像
avatarPaths := make(map[int64]string) avatarPaths := make(map[int64]string)
dir := filepath.Dir(jsonlFile) dir := filepath.Dir(jsonlFile)
@@ -267,6 +278,57 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
return ctx.burnChunked(chunks) return ctx.burnChunked(chunks)
} }
// 渲染前按最终字号重排弹道:jsonl 里的 lane/width 是录制或 .ass 还原时算的,
// 从原格式 .ass 还原的弹幕没把昵称和头像算进宽度(占位提前放行了同弹道的下一条,
// 画面上就追尾叠字),改过字号后更是全对不上。这里用和录制时相同的碰撞公式、
// 按「昵称(uid):内容 + 头像」的完整宽度重新分配一遍,保证弹道不重叠。
func relayoutLanes(records []danmuRecord, meta danmuMeta) {
sort.SliceStable(records, func(i, j int) bool { return records[i].Time < records[j].Time })
fs := meta.FontSize
show := int64(danmuShowSeconds * 1e9)
resX := int64(meta.PlayResX)
avW, avGap := avatarSize(fs), avatarGap(fs)
// 空弹道用「远古」状态占位:placeLane 的条件是本条出现时间晚于上一条的让位时刻,
// 零值下 0 > 0 不成立,第一条弹幕(还原出来的弹幕时间可能正好是 0)会占不上弹道,
// 占用状态丢掉后后面的弹幕又会跟着落回空弹道,等于白排。录制时弹幕时间总大于 0,
// 所以原逻辑没暴露这个问题。
lanes := make([]laneState, laneNum)
for i := range lanes {
lanes[i] = laneState{appear: math.MinInt64, emerge: math.MinInt64, disappear: math.MinInt64}
}
for i := range records {
r := &records[i]
r.Width = textWidth(speakerText(r.Name, r.UID, r.Content), fs)
total := int64(avW+avGap) + int64(r.Width)
rel := int64(r.Time * 1e9)
dt := laneState{
appear: rel,
emerge: rel + total*show/(resX+total),
disappear: rel + show,
}
left := rel + resX*show/(resX+total)
j := placeLane(lanes, dt, left)
if j < 0 {
// 100 条弹道都还占着:退到最早让出来的那条。录制时是直接不写这条,
// 合并时丢一条等于凭空少一句弹幕,压一点也比丢了强。
j = earliestLane(lanes)
lanes[j] = dt
}
r.Lane = j
}
}
// 最早会空出来的弹道行
func earliestLane(lanes []laneState) int {
best := 0
for i := range lanes {
if lanes[i].disappear < lanes[best].disappear {
best = i
}
}
return best
}
// 用 ffmpeg 探测媒体文件的时长(秒),探测不到返回 0 // 用 ffmpeg 探测媒体文件的时长(秒),探测不到返回 0
func probeDuration(ffmpegFile, path string) float64 { func probeDuration(ffmpegFile, path string) float64 {
cmd := exec.Command(ffmpegFile, "-hide_banner", "-i", path) cmd := exec.Command(ffmpegFile, "-hide_banner", "-i", path)
@@ -687,9 +749,9 @@ func downloadAvatar(uid int64, url, path string) error {
if err != nil { if err != nil {
return err return err
} }
src, _, err := image.Decode(bytes.NewReader(data)) src, err := decodeAvatar(data, resp.Header.Get("Content-Type"))
if err != nil { if err != nil {
return fmt.Errorf("解析失败(可能是 webp 格式):%v", err) return err
} }
out, err := os.Create(path) out, err := os.Create(path)
@@ -700,6 +762,17 @@ func downloadAvatar(uid int64, url, path string) error {
return png.Encode(out, circleAvatar(src, avatarCacheSize)) return png.Encode(out, circleAvatar(src, avatarCacheSize))
} }
// 解码头像图片。AcFun 的头像里混着 gif 和 webp:
// gif 由标准库解码,多帧的取第一帧;webp 只支持静态的,动画 webp 会报错,
// 这种情况下这个用户就不烧头像,只烧文字。
func decodeAvatar(data []byte, contentType string) (image.Image, error) {
src, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("图片解析不了(Content-Type: %s):%v", contentType, err)
}
return src, nil
}
// uid → 头像链接的记忆,包含查不到的结果,避免同一个 uid 反复去问 // uid → 头像链接的记忆,包含查不到的结果,避免同一个 uid 反复去问
var avatarURLMemo struct { var avatarURLMemo struct {
sync.Mutex sync.Mutex
+19
View File
@@ -97,6 +97,8 @@ web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全
合并状态通过`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):内容 + 头像」的完整宽度,占位时间更长,照搬原弹道会追尾叠字;重排按完整宽度重新分配,同一条弹道上不会再有两条弹幕叠在一起。
`POST /api/notify/test` 测试某个推送渠道能不能连通。用传进来的值直接测,留空的项自动用已保存的配置补齐,所以不用先保存设置。测试不受渠道开关和事件勾选影响。 `POST /api/notify/test` 测试某个推送渠道能不能连通。用传进来的值直接测,留空的项自动用已保存的配置补齐,所以不用先保存设置。测试不受渠道开关和事件勾选影响。
``` ```
@@ -109,3 +111,20 @@ web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全
成功后返回`{"ok":true,"detail":"webhook 返回 HTTP 200,用时 0.31 秒。…"}`,`detail`就是页面上显示的那句结论。失败时返回 502 并在响应体里给中文原因(如`MeoW 返回 status 404(昵称未注册)`),参数不合法返回 400。 成功后返回`{"ok":true,"detail":"webhook 返回 HTTP 200,用时 0.31 秒。…"}`,`detail`就是页面上显示的那句结论。失败时返回 502 并在响应体里给中文原因(如`MeoW 返回 status 404(昵称未注册)`),参数不合法返回 400。
MeoW 的`status`和封禁规则会反映在`GET /api/status`的`meowBlocked`字段里:昵称没填、格式不对、或已被 MeoW 判过「未注册」时,这里会给出中文原因,非空就说明 MeoW 渠道当前推不出去。注意 MeoW 一天内累计 3 次推给不存在的昵称会封 IP,所以被判过不存在的昵称会被程序记住并停止重试,改昵称或重启程序才恢复。 MeoW 的`status`和封禁规则会反映在`GET /api/status`的`meowBlocked`字段里:昵称没填、格式不对、或已被 MeoW 判过「未注册」时,这里会给出中文原因,非空就说明 MeoW 渠道当前推不出去。注意 MeoW 一天内累计 3 次推给不存在的昵称会封 IP,所以被判过不存在的昵称会被程序记住并停止重试,改昵称或重启程序才恢复。
`GET /api/browse` 给页面上的`浏览`按钮列目录用。参数:`path`(要展开的目录,不填就用程序当前的工作目录)、`kind=file`(连文件一起列,挑文件时用)、`hidden=1`(连以点开头的隐藏项一起列)。页面上的`浏览`有单选(保存目录、ffmpeg 路径、弹幕文件)和多选(手动合并的视频文件)两种用法,接口每次只负责列一层,多选的勾选状态由页面自己保管,所以可以换着目录勾、跨文件夹攒;填回框里时,录播目录下的文件写文件名、别的文件夹里的写绝对路径。
```
{
"path": "D:\\record", // 当前展开的目录,已转成绝对路径
"parent": "D:\\", // 上级目录,已经到根时是空串
"roots": [{"name": "C:", "path": "C:\\", "dir": true, "size": 0}],
"entries": [{"name": "2026-09-25.mp4", "path": "D:\\record\\2026-09-25.mp4", "dir": false, "size": 123}],
"truncated": false, // 条目太多时只返回前 2000 项
"error": "" // 打不开时的中文原因
}
```
文件夹排在文件前面,同类按名字排(不区分大小写)。挑文件时文件夹照样会列出来,不然进不去子目录。`error`非空时仍是 HTTP 200,`entries`是空的,但`parent`会带上——页面据此退到上一级,不会把用户卡在一个打不开的路径里(比如把文件路径当目录展开时)。
这个接口能看到整台机器的目录,但权限和其它管理接口一样,都是拿到口令即全部权限,没有放宽。
+4
View File
@@ -111,6 +111,10 @@ func listFiles(dir string) []fileEntry {
if e.IsDir() { if e.IsDir() {
continue continue
} }
// 合成过程中的临时文件(拼接、分块、本地临时成片)不上列表
if strings.HasPrefix(e.Name(), chunkTempPrefix) || strings.HasSuffix(e.Name(), partSuffix) {
continue
}
info, err := e.Info() info, err := e.Info()
if err != nil { if err != nil {
continue continue
+130
View File
@@ -0,0 +1,130 @@
package main
import "testing"
// 还原自 .ass 的弹幕:原格式的弹道是按「只有内容」的宽度占位的,渲染用的是
// 「昵称(uid):内容 + 头像」的宽度,占位更长 → 同弹道会追尾。重排后必须分开。
func TestRelayoutSeparatesRestoredOverlap(t *testing.T) {
meta := danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 60}
content := "哈哈哈哈哈哈哈哈"
// 两条原本同弹道、间隔 2.5 秒:按内容宽度(8×60=480)算,2.5 秒时前一条已经让位;
// 按昵称头像宽度(头像48+间隔10+文本720)算,前一条要到约 2.9 秒才让位
records := []danmuRecord{
{Time: 0, UID: 101, Name: "甲", Content: content, Lane: 0, Width: 480},
{Time: 2.5, UID: 202, Name: "乙", Content: content, Lane: 0, Width: 480},
}
relayoutLanes(records, meta)
if records[0].Lane == records[1].Lane {
t.Fatalf("两条追尾的弹幕重排后仍在同一条弹道 %d", records[0].Lane)
}
// 宽度也要按昵称头像版重算,否则滚动速度还是错的
want := textWidth(speakerText("甲", 101, content), meta.FontSize)
if records[0].Width != want {
t.Errorf("宽度应重算为 %d,实际 %d", want, records[0].Width)
}
}
// 录制时存下来的弹道本来就是按同一套公式算的,重排应当原样保留
func TestRelayoutKeepsLiveLayout(t *testing.T) {
meta := danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 48}
var records []danmuRecord
for i := 0; i < 40; i++ {
records = append(records, danmuRecord{
Time: float64(i) * 0.4, UID: int64(100 + i), Name: "观众", Content: "测试弹幕内容",
})
}
relayoutLanes(records, meta)
first := make([]int, len(records))
for i, r := range records {
first[i] = r.Lane
}
relayoutLanes(records, meta)
for i, r := range records {
if r.Lane != first[i] {
t.Fatalf("第 %d 条重排两次结果不一致:%d → %d", i, first[i], r.Lane)
}
}
}
// 输入顺序乱了也要按时间重排,否则弹道占用顺序错乱
func TestRelayoutSortsByTime(t *testing.T) {
meta := danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 48}
shuffled := []danmuRecord{
{Time: 5, UID: 1, Name: "甲", Content: "第三条"},
{Time: 1, UID: 2, Name: "乙", Content: "第一条"},
{Time: 3, UID: 3, Name: "丙", Content: "第二条"},
}
sorted := []danmuRecord{
{Time: 1, UID: 2, Name: "乙", Content: "第一条"},
{Time: 3, UID: 3, Name: "丙", Content: "第二条"},
{Time: 5, UID: 1, Name: "甲", Content: "第三条"},
}
relayoutLanes(shuffled, meta)
relayoutLanes(sorted, meta)
for i := range sorted {
if shuffled[i].Lane != sorted[i].Lane {
t.Errorf("第 %d 条弹道不一致:%d vs %d", i, shuffled[i].Lane, sorted[i].Lane)
}
}
}
// 字号越小,同一条弹幕在屏幕上停留时占用的时间越短,弹道占用也更宽松
func TestRelayoutWidthScalesWithFontSize(t *testing.T) {
big := []danmuRecord{{Time: 0, UID: 7, Name: "甲", Content: "短弹幕"}}
small := []danmuRecord{{Time: 0, UID: 7, Name: "甲", Content: "短弹幕"}}
relayoutLanes(big, danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 60})
relayoutLanes(small, danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 30})
if big[0].Width <= small[0].Width {
t.Errorf("字号 60 的宽度 %d 应大于字号 30 的 %d", big[0].Width, small[0].Width)
}
if big[0].Width != small[0].Width*2 {
t.Errorf("宽度应随字号等比缩放:60 → %d,30 → %d", big[0].Width, small[0].Width)
}
}
// 密集弹幕(弹道信息全是坏的:lane 都是 0、width 都是 0)重排后不应再有追尾,
// 也不该超过弹道行数上限
func TestRelayoutNoOverlapOnDenseInput(t *testing.T) {
meta := danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 60}
texts := []string{"短", "中等长度的一条弹幕", "这是一条很长的弹幕内容用来占位置", "colong"}
var records []danmuRecord
t0 := 0.0
for i := 0; i < 600; i++ {
records = append(records, danmuRecord{
Time: t0,
UID: int64(1000 + i%7),
Name: "观众" + string(rune('A'+i%26)),
Content: texts[i%len(texts)],
Lane: 0,
Width: 0,
})
t0 += 0.05 + float64(i%5)*0.07 // 0.05 ~ 0.33 秒一条
}
relayoutLanes(records, meta)
show := int64(danmuShowSeconds * 1e9)
resX := int64(meta.PlayResX)
type state struct{ emerge int64 }
last := map[int]state{}
for i, r := range records {
if r.Lane < 0 || r.Lane >= laneNum {
t.Fatalf("第 %d 条弹道越界:%d", i, r.Lane)
}
total := int64(avatarSize(meta.FontSize)+avatarGap(meta.FontSize)) + int64(r.Width)
if total <= 0 {
t.Fatalf("第 %d 条宽度没算出来:%d", i, r.Width)
}
rel := int64(r.Time * 1e9)
emerge := rel + total*show/(resX+total)
if prev, ok := last[r.Lane]; ok && rel < prev.emerge {
t.Fatalf("第 %d 条和上一条挤在同弹道 %d:出现 %v 早于上一条让位 %v", i, r.Lane, r.Time, float64(prev.emerge)/1e9)
}
last[r.Lane] = state{emerge}
}
}
+12 -3
View File
@@ -470,17 +470,26 @@ func runManualMerge(plan mergePlan) error {
if _, err := os.Stat(plan.out); err == nil { if _, err := os.Stat(plan.out); err == nil {
lPrintWarn("成片已存在,将被覆盖:" + plan.out) lPrintWarn("成片已存在,将被覆盖:" + plan.out)
} }
// 先在本地(弹幕所在的录播目录)合成,完成后再落到成片目录:
// 成片目录可能是网络盘或外接盘,直接往里写、写到一半失败会在那里留个半成品
tmpOut := tempMergeOutput(plan.danmu, filepath.Base(plan.out))
lPrintf("手动合并开始:视频 %s,弹幕 %s,偏移 %.3f 秒(%s)", lPrintf("手动合并开始:视频 %s,弹幕 %s,偏移 %.3f 秒(%s)",
filepath.Base(video), filepath.Base(jsonl), offset, note) filepath.Base(video), filepath.Base(jsonl), offset, note)
lPrintf("先在本地合成 %s,完成后落到 %s", filepath.Base(tmpOut), plan.out)
// 标记成正在写入,避免在文件管理里把还没写完的成片删掉 // 标记成正在写入,避免在文件管理里把还没写完的成片删掉
markFileActive(plan.out, true) markFileActive(tmpOut, true)
burnErr := burnDanmu(jsonl, video, plan.out, offset) burnErr := burnDanmu(jsonl, video, tmpOut, offset)
markFileActive(plan.out, false) markFileActive(tmpOut, false)
if burnErr != nil { if burnErr != nil {
_ = os.Remove(tmpOut)
return burnErr return burnErr
} }
if err := placeOutput(tmpOut, plan.out); err != nil {
_ = os.Remove(tmpOut)
return err
}
lPrintln("手动合并完成:" + plan.out) lPrintln("手动合并完成:" + plan.out)
return nil return nil
+137
View File
@@ -0,0 +1,137 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// 合成好的本地临时文件落到成片目录:同盘走改名,本地不该再留文件
func TestPlaceOutputMovesFile(t *testing.T) {
setupMergeTest(t)
tmp := filepath.Join(recordSaveDir(), chunkTempPrefix+"merging_测试_弹幕合并.mp4")
writeTestFile(t, tmp, "成片内容")
out := filepath.Join(mergeSaveDir(), "测试_弹幕合并.mp4")
if err := placeOutput(tmp, out); err != nil {
t.Fatalf("落盘失败:%v", err)
}
got, err := os.ReadFile(out)
if err != nil || string(got) != "成片内容" {
t.Fatalf("成片内容不对:%q %v", got, err)
}
if _, err := os.Stat(tmp); !os.IsNotExist(err) {
t.Error("落盘之后本地临时文件应该已经不在了")
}
}
// 成片目录里的子目录不存在时自动创建,已有同名成片时直接覆盖
func TestPlaceOutputCreatesDirAndOverwrites(t *testing.T) {
setupMergeTest(t)
sub := filepath.Join(mergeSaveDir(), "按日期", "2026-09")
if err := os.MkdirAll(sub, 0755); err != nil {
t.Fatal(err)
}
out := filepath.Join(sub, "同名_弹幕合并.mp4")
writeTestFile(t, out, "上一次的成片")
tmp := filepath.Join(recordSaveDir(), chunkTempPrefix+"merging_同名_弹幕合并.mp4")
writeTestFile(t, tmp, "这一次的成片")
if err := placeOutput(tmp, out); err != nil {
t.Fatalf("落盘失败:%v", err)
}
got, err := os.ReadFile(out)
if err != nil || string(got) != "这一次的成片" {
t.Fatalf("应该覆盖成新内容,实际:%q %v", got, err)
}
// 成片目录里不该留 .part
entries, _ := os.ReadDir(sub)
for _, e := range entries {
if strings.HasSuffix(e.Name(), partSuffix) {
t.Errorf("不该留下 %s", e.Name())
}
}
}
// 本地临时文件不存在时报错,而且不在成片目录留下任何东西
func TestPlaceOutputMissingSourceLeavesNothing(t *testing.T) {
setupMergeTest(t)
out := filepath.Join(mergeSaveDir(), "没有源文件_弹幕合并.mp4")
if err := placeOutput(filepath.Join(recordSaveDir(), "不存在.mp4"), out); err == nil {
t.Fatal("源文件不存在时应该报错")
}
entries, err := os.ReadDir(mergeSaveDir())
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if e.Name() == filepath.Base(out) || strings.HasSuffix(e.Name(), partSuffix) {
t.Errorf("失败之后不该留下 %s", e.Name())
}
}
}
// 临时成片放在弹幕文件所在目录(本地),且带临时前缀(否则会出现在文件管理里)
func TestTempMergeOutputPath(t *testing.T) {
dir := t.TempDir()
danmu := filepath.Join(dir, "record", "2026-09-25 01-00-00 甲.jsonl")
got := tempMergeOutput(danmu, "2026-09-25 01-00-00 甲_弹幕合并.mp4")
want := filepath.Join(dir, "record", chunkTempPrefix+"merging_2026-09-25 01-00-00 甲_弹幕合并.mp4")
if got != want {
t.Errorf("临时路径不对:\n got %s\nwant %s", got, want)
}
if !strings.HasPrefix(filepath.Base(got), chunkTempPrefix) {
t.Error("临时文件必须带临时前缀,否则会被当成正常文件列出")
}
}
// 启动时清理残留的临时文件,正常的录播 / 成片不动
func TestCleanTempFiles(t *testing.T) {
setupMergeTest(t)
staleRecord := []string{
filepath.Join(recordSaveDir(), chunkTempPrefix+"joined.mp4"),
filepath.Join(recordSaveDir(), chunkTempPrefix+"remapped.jsonl"),
filepath.Join(recordSaveDir(), chunkTempPrefix+"merging_甲_弹幕合并.mp4"),
}
staleMerge := filepath.Join(mergeSaveDir(), "甲_弹幕合并.mp4"+partSuffix)
for _, p := range append(append([]string{}, staleRecord...), staleMerge) {
writeTestFile(t, p, "残留")
}
keep := []string{
writeTestFile(t, filepath.Join(recordSaveDir(), "2026-09-25 01-00-00 甲.jsonl"), "弹幕"),
writeTestFile(t, filepath.Join(recordSaveDir(), "2026-09-25 01-00-00 甲.mp4"), "视频"),
writeTestFile(t, filepath.Join(mergeSaveDir(), "2026-09-25 01-00-00 甲_弹幕合并.mp4"), "成片"),
}
cleanTempFiles(recordSaveDir())
cleanTempFiles(mergeSaveDir())
for _, p := range append(append([]string{}, staleRecord...), staleMerge) {
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Errorf("残留的临时文件应该被清掉:%s", filepath.Base(p))
}
}
for _, p := range keep {
if _, err := os.Stat(p); err != nil {
t.Errorf("正常文件不该被删:%s", filepath.Base(p))
}
}
}
// 临时文件不出现在文件管理的列表里
func TestListFilesHidesTemp(t *testing.T) {
setupMergeTest(t)
writeTestFile(t, filepath.Join(recordSaveDir(), "2026-09-25 01-00-00 甲.mp4"), "视频")
writeTestFile(t, filepath.Join(recordSaveDir(), chunkTempPrefix+"merging_甲_弹幕合并.mp4"), "临时")
writeTestFile(t, filepath.Join(recordSaveDir(), "甲_弹幕合并.mp4"+partSuffix), "半成品")
names := make([]string, 0, 4)
for _, f := range listFiles(recordSaveDir()) {
names = append(names, f.Name)
}
if len(names) != 1 || names[0] != "2026-09-25 01-00-00 甲.mp4" {
t.Errorf("列表里应该只剩正常录播文件,实际:%v", names)
}
}
+80
View File
@@ -7,7 +7,9 @@ import (
"io" "io"
"log" "log"
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings"
"sync" "sync"
"time" "time"
@@ -257,3 +259,81 @@ func isRecording(liveID string) bool {
} }
return false return false
} }
// 跨盘搬运成片时的临时后缀:拷一半失败也能一眼看出不是成品
const partSuffix = ".acfunlive.part"
// 合成时的临时成片放在弹幕文件所在目录(本地),
// 完成后再落到成片目录 —— 成片目录可能是网络盘或外接盘,直接往里写、
// 写到一半失败会在那里留下一个看起来正常的半成品。文件名沿用临时前缀,
// 所以不会出现在文件管理的列表里,启动时也会清理残留。
func tempMergeOutput(danmuFile, outBase string) string {
return filepath.Join(filepath.Dir(danmuFile), chunkTempPrefix+"merging_"+outBase)
}
// 把合成好的本地临时文件落到成片位置:同盘直接改名(瞬时),跨盘退回拷贝
func placeOutput(tmp, out string) error {
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
return fmt.Errorf("创建成片文件夹失败:%v", err)
}
if err := os.Rename(tmp, out); err == nil {
return nil
}
// 跨盘时 os.Rename 会失败(Windows 上尤其明确),退回拷贝:
// 先拷成 .part 再改名,中途失败也不会在成片目录留下像成品的文件
part := out + partSuffix
if err := copyFile(tmp, part); err != nil {
_ = os.Remove(part)
return fmt.Errorf("拷贝到成片目录 %s 失败:%v", filepath.Dir(out), err)
}
if err := os.Rename(part, out); err != nil {
_ = os.Remove(part)
return fmt.Errorf("成片改名失败:%v", err)
}
_ = os.Remove(tmp)
return nil
}
// 拷贝文件,跨盘搬运成片时用
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
return out.Close()
}
// 清掉上次运行残留的合成临时文件。正常结束时都会删掉,这里是兜底:
// 程序在合成中途被强杀时,临时文件会留在录播目录或成片目录里。
// 注意强杀不会连带杀掉 ffmpeg 子进程,它可能还在写这个文件(Windows 上就删不掉),
// 所以删不掉时只提示一句,下次启动会再清一次。
func cleanTempFiles(dir string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasPrefix(name, chunkTempPrefix) && !strings.HasSuffix(name, partSuffix) {
continue
}
path := filepath.Join(dir, name)
if err := os.Remove(path); err == nil {
lPrintln("清理上次留下的临时文件:" + name)
} else {
lPrintWarnf("临时文件 %s 现在删不掉(多半是上次强杀程序后残留的 ffmpeg 还在写),下次启动会自动清理:%v", path, err)
}
}
}
+387 -48
View File
@@ -67,6 +67,11 @@ const webuiHTML = `<!DOCTYPE html>
.dot{width:6px;height:6px;border-radius:50%;background:var(--sub);display:inline-block;flex:none} .dot{width:6px;height:6px;border-radius:50%;background:var(--sub);display:inline-block;flex:none}
.dot.rec{background:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)} .dot.rec{background:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.dot.live{background:var(--ok);box-shadow:0 0 0 3px var(--ok-soft)} .dot.live{background:var(--ok);box-shadow:0 0 0 3px var(--ok-soft)}
.dot.warn{background:var(--bad);box-shadow:0 0 0 3px var(--accent-soft)}
/* 有没提交的修改时把保存按钮标出来,手机上光看输入框看不出来 */
#saveBtn{position:relative}
#saveBtn.dirty{box-shadow:0 0 0 3px var(--accent-soft)}
#saveBtn.dirty::after{content:"";position:absolute;top:-4px;right:-4px;width:9px;height:9px;border-radius:50%;background:var(--bad);border:2px solid var(--card)}
/* 分区导航 */ /* 分区导航 */
.tabs{max-width:1060px;margin:0 auto;padding:0 14px 10px;display:flex;gap:4px;overflow-x:auto;scrollbar-width:none} .tabs{max-width:1060px;margin:0 auto;padding:0 14px 10px;display:flex;gap:4px;overflow-x:auto;scrollbar-width:none}
.tabs::-webkit-scrollbar{display:none} .tabs::-webkit-scrollbar{display:none}
@@ -150,6 +155,32 @@ const webuiHTML = `<!DOCTYPE html>
.mask h3{margin:0 0 6px;font-size:15px} .mask h3{margin:0 0 6px;font-size:15px}
.mask p{margin:0 0 12px;font-size:12px;color:var(--sub);line-height:1.65} .mask p{margin:0 0 12px;font-size:12px;color:var(--sub);line-height:1.65}
#authMsg{font-size:12px;color:var(--bad);min-height:17px;margin-top:8px} #authMsg{font-size:12px;color:var(--bad);min-height:17px;margin-top:8px}
/* 路径输入框:手工填和点「浏览」并存 */
.pathf{display:flex;gap:8px;align-items:center}
.pathf input{flex:1 1 auto;min-width:0;max-width:none}
.pathf button{flex:none}
/* 路径选择弹窗 */
#pick .box{max-width:620px}
#pickPath{font-size:12.5px}
.roots{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}
.roots:empty{display:none}
.roots button{background:var(--soft);color:var(--text);font-size:12px;padding:4px 10px;border-radius:999px}
.roots button:hover{background:var(--soft2)}
.plist{max-height:44vh;overflow:auto;border:1px solid var(--line);border-radius:var(--radius-s);background:var(--soft2)}
.plist .off{padding:10px}
.pi{display:flex;align-items:center;gap:10px;padding:7px 10px;font-size:13px;cursor:pointer}
.pi+.pi{border-top:1px solid var(--line)}
.pi:hover{background:var(--card)}
.pi.on{background:var(--accent-soft);color:var(--accent)}
.pi-n{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pi-dir .pi-n{font-weight:600}
.pi-m{flex:none;font-size:11.5px;color:var(--sub);font-variant-numeric:tabular-nums}
/* 多选模式(选视频这种可以选多个的框):文件条目左边给个勾选框 */
.pi-c{display:none;flex:none;width:15px;height:15px;border:1px solid var(--line);border-radius:4px;background:var(--card)}
#pick.multi .pi:not(.pi-dir) .pi-c{display:block}
#pick.multi .pi.on .pi-c{border-color:var(--accent);background:var(--accent)}
#pick.multi .pi.on .pi-c::after{content:"";display:block;width:4px;height:8px;margin:1px 0 0 4px;border:solid #fff;border-width:0 2px 2px 0;transform:rotate(45deg)}
.lnk{color:var(--accent);cursor:pointer;text-decoration:underline}
.only-m{display:none} .only-m{display:none}
@media (max-width:700px){ @media (max-width:700px){
/* 手机上标题居中、两侧各留一列等宽(左右各只有一个按钮,视觉上才是正的居中) */ /* 手机上标题居中、两侧各留一列等宽(左右各只有一个按钮,视觉上才是正的居中) */
@@ -181,6 +212,7 @@ const webuiHTML = `<!DOCTYPE html>
<div class="bt"><strong>AcFun 直播录制</strong><span class="head-sub">弹幕 / 视频录制,直播结束后自动合并</span></div> <div class="bt"><strong>AcFun 直播录制</strong><span class="head-sub">弹幕 / 视频录制,直播结束后自动合并</span></div>
</div> </div>
<div class="top-act"> <div class="top-act">
<span class="stat only-d" id="dirtyTip" style="display:none"><i class="dot warn"></i>有未保存的修改</span>
<span class="stat" id="topStat"></span> <span class="stat" id="topStat"></span>
<button id="saveBtn" onclick="saveSettings(this)"><span class="only-d">保存设置</span><span class="only-m">保存</span></button> <button id="saveBtn" onclick="saveSettings(this)"><span class="only-d">保存设置</span><span class="only-m">保存</span></button>
</div> </div>
@@ -221,11 +253,16 @@ const webuiHTML = `<!DOCTYPE html>
<div class="card"> <div class="card">
<div class="card-h"><h2>保存位置</h2><span class="sub">录播、弹幕和成片都放在这里</span></div> <div class="card-h"><h2>保存位置</h2><span class="sub">录播、弹幕和成片都放在这里</span></div>
<div class="grid"> <div class="grid">
<div class="f"><label>文件保存目录(留空 = 程序所在目录)</label><input type="text" id="directory" placeholder="例如 D:\record"></div> <div class="f"><label>文件保存目录(留空 = 程序所在目录)</label>
<div class="pathf">
<input type="text" id="directory" placeholder="例如 D:\record">
<button class="ghost" onclick="openPick('directory','dir')">浏览…</button>
</div>
</div>
<div class="f"><label>录播 / 弹幕子文件夹</label><input type="text" id="recordSubdir" placeholder="record"></div> <div class="f"><label>录播 / 弹幕子文件夹</label><input type="text" id="recordSubdir" placeholder="record"></div>
<div class="f"><label>成片子文件夹(在保存目录下)</label><input type="text" id="mergeSubdir" placeholder="merged"></div> <div class="f"><label>成片子文件夹(在保存目录下)</label><input type="text" id="mergeSubdir" placeholder="merged"></div>
</div> </div>
<div class="hint">文本框改完点右上角「保存设置」生效,开关和下拉框改完立即生效。</div> <div class="hint">文本框改完按回车或点右上角「保存设置」生效,开关和下拉框改完立即生效;没保存就离开页面或刷新会丢掉改动。</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-h"><h2>录制参数</h2></div> <div class="card-h"><h2>录制参数</h2></div>
@@ -237,7 +274,12 @@ const webuiHTML = `<!DOCTYPE html>
<option value="mkv">mkv(可装多音轨)</option> <option value="mkv">mkv(可装多音轨)</option>
</select></div> </select></div>
<div class="f"><label>默认下载码率上限(Kbps,0 = 最高档)</label><input type="text" id="defaultBitrate" inputmode="numeric" placeholder="例如 2000"></div> <div class="f"><label>默认下载码率上限(Kbps,0 = 最高档)</label><input type="text" id="defaultBitrate" inputmode="numeric" placeholder="例如 2000"></div>
<div class="f"><label>ffmpeg 路径(留空自动查找)</label><input type="text" id="ffmpegPath" placeholder="例如 D:\ffmpeg\bin\ffmpeg.exe"></div> <div class="f"><label>ffmpeg 路径(留空自动查找)</label>
<div class="pathf">
<input type="text" id="ffmpegPath" placeholder="例如 D:\ffmpeg\bin\ffmpeg.exe">
<button class="ghost" onclick="openPick('ffmpegPath','file')">浏览…</button>
</div>
</div>
</div> </div>
<div class="hint">录播和弹幕合并都要用 ffmpeg:留空时先查 PATH,再查程序所在目录及其 bin、ffmpeg 子目录。</div> <div class="hint">录播和弹幕合并都要用 ffmpeg:留空时先查 PATH,再查程序所在目录及其 bin、ffmpeg 子目录。</div>
<div class="hint">录制是 -c copy 直接封装,不转码,四种格式画质完全一样,只是容器不同。正常停止(停止按钮、Ctrl+C)都没问题;如果进程被强杀(任务管理器结束进程、断电),mp4 / flv / mkv 都会整个报废,实测只有 ts 还能播出已经录到的部分。合并出来的成片始终是 mp4。</div> <div class="hint">录制是 -c copy 直接封装,不转码,四种格式画质完全一样,只是容器不同。正常停止(停止按钮、Ctrl+C)都没问题;如果进程被强杀(任务管理器结束进程、断电),mp4 / flv / mkv 都会整个报废,实测只有 ts 还能播出已经录到的部分。合并出来的成片始终是 mp4。</div>
@@ -259,10 +301,16 @@ const webuiHTML = `<!DOCTYPE html>
<div class="card"> <div class="card">
<div class="card-h"><h2>成片输出</h2></div> <div class="card-h"><h2>成片输出</h2></div>
<div class="grid"> <div class="grid">
<div class="f"><label>成片移动到(留空 = 保存目录下的成片文件夹)</label><input type="text" id="mergeOutputDir" placeholder="例如 D:\merged"></div> <div class="f"><label>成片移动到(留空 = 保存目录下的成片文件夹)</label>
<div class="f"><label>合成码率(Kbps,0 = 默认质量)</label><input type="text" id="mergeBitrate" inputmode="numeric" placeholder="例如 1500"></div> <div class="pathf">
<input type="text" id="mergeOutputDir" placeholder="例如 D:\merged">
<button class="ghost" onclick="openPick('mergeOutputDir','dir')">浏览…</button>
</div> </div>
<div class="hint">文本框改完点右上角「保存设置」生效。</div> </div>
<div class="f"><label>合成码率(Kbps,0 = 默认质量)</label><input type="text" id="mergeBitrate" inputmode="numeric" placeholder="例如 1500"></div>
<div class="f"><label>弹幕字号(像素,0 = 跟随弹幕文件)</label><input type="text" id="danmuFontSize" inputmode="numeric" placeholder="例如 42,头像随字号一起缩放"></div>
</div>
<div class="hint">文本框改完按回车或点右上角「保存设置」生效,不做这一步的话 3 秒后会被刷回原值。</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-h"><h2>手动合并</h2><span class="sub">自己挑视频和弹幕合成</span></div> <div class="card-h"><h2>手动合并</h2><span class="sub">自己挑视频和弹幕合成</span></div>
@@ -271,10 +319,17 @@ const webuiHTML = `<!DOCTYPE html>
<div class="f"> <div class="f">
<label>视频文件(一行一个,也可填绝对路径)</label> <label>视频文件(一行一个,也可填绝对路径)</label>
<textarea id="mrgVideos" rows="3" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.mp4"></textarea> <textarea id="mrgVideos" rows="3" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.mp4"></textarea>
<div class="pathf" style="margin-top:8px">
<button class="ghost" onclick="openPick('mrgVideos','file',1)">浏览…</button>
<span class="hint" style="margin:0;flex:1 1 auto;min-width:0">可多选,能换文件夹继续勾;选完追加到框里,不清掉已填内容</span>
</div>
</div> </div>
<div class="f"> <div class="f">
<label>弹幕文件(.jsonl 直接用,.ass 会先还原成 .jsonl)</label> <label>弹幕文件(.jsonl 直接用,.ass 会先还原成 .jsonl)</label>
<div class="pathf">
<input type="text" id="mrgDanmu" list="mrgDanmuList" autocomplete="off" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.jsonl"> <input type="text" id="mrgDanmu" list="mrgDanmuList" autocomplete="off" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.jsonl">
<button class="ghost" onclick="openPick('mrgDanmu','file')">浏览…</button>
</div>
<datalist id="mrgDanmuList"></datalist> <datalist id="mrgDanmuList"></datalist>
</div> </div>
</div> </div>
@@ -341,7 +396,7 @@ const webuiHTML = `<!DOCTYPE html>
<div class="hint bad" id="meowBlocked" style="display:none"></div> <div class="hint bad" id="meowBlocked" style="display:none"></div>
<div class="bar"> <div class="bar">
<button class="ghost" onclick="testNotify(this,'meow')">测试连通</button> <button class="ghost" onclick="testNotify(this,'meow')">测试连通</button>
<span class="hint">用上面填写的昵称发一条测试推送,不用先保存;改完记得点右上角「保存设置」</span> <span class="hint">用上面填写的昵称发一条测试推送,不用先保存;改完记得按回车或点右上角「保存设置」</span>
</div> </div>
</div> </div>
</section> </section>
@@ -394,6 +449,26 @@ const webuiHTML = `<!DOCTYPE html>
<div id="authMsg"></div> <div id="authMsg"></div>
</div> </div>
</div> </div>
<div class="mask" id="pick">
<div class="box pbox">
<h3 id="pickTitle">选择文件夹</h3>
<p id="pickDesc">逐级点选即可;也可以直接在地址栏里填或粘贴路径后按回车。选好后点下面的按钮填进设置框。</p>
<div class="pathf" style="margin-bottom:8px">
<button class="ghost" id="pickUp" onclick="pickUp()">↑ 上一级</button>
<input type="text" id="pickPath" spellcheck="false" autocomplete="off" placeholder="也可以直接填或粘贴路径,回车跳转">
<button class="ghost" onclick="pickGo(document.getElementById('pickPath').value)">转到</button>
</div>
<div class="roots" id="pickRoots"></div>
<div class="plist" id="pickList"></div>
<div class="hint" id="pickMsg"></div>
<div class="bar" style="margin-top:12px">
<label class="sw" style="margin-right:auto"><input type="checkbox" id="pickHidden"><span class="sw-i"></span>显示隐藏项</label>
<span class="hint" id="pickSel" style="margin:0"></span>
<button class="ghost" onclick="closePick()">取消</button>
<button id="pickOk" onclick="pickConfirm()">选用这个文件夹</button>
</div>
</div>
</div>
<script> <script>
var esc = function(s){ return (s||"").replace(/[&<>"]/g, function(c){ return {"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;"}[c]; }); }; var esc = function(s){ return (s||"").replace(/[&<>"]/g, function(c){ return {"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;"}[c]; }); };
var saving = false; // 正在保存设置时暂停定时刷新对表单的覆写 var saving = false; // 正在保存设置时暂停定时刷新对表单的覆写
@@ -402,8 +477,52 @@ var logOffset = "0"; // 已经读取到的日志偏移量
var token = localStorage.getItem("acfunlive_token") || ""; // 管理口令,存在浏览器本地 var token = localStorage.getItem("acfunlive_token") || ""; // 管理口令,存在浏览器本地
var authed = false; // 是否已通过口令校验,未通过时停止轮询 var authed = false; // 是否已通过口令校验,未通过时停止轮询
var tokenLoaded = false; var tokenLoaded = false;
function setChk(id, v){ var el = document.getElementById(id); if(!saving && !el.matches(":focus")) el.checked = !!v; } // 设置表单里的字段,元素 id 与 /api/config 的字段名一致
function setVal(id, v){ var el = document.getElementById(id); if(!saving && document.activeElement !== el) el.value = v; } var settingFields = ["directory","autoMerge","mergeDelVideo","mergeDelDanmu","mergeOutputDir","mergeBitrate","danmuFontSize","defaultBitrate","recordSubdir","mergeSubdir","ffmpegPath","recordFormat","notifyEnabled","notifyUrl","notifyToken","notifyGroup","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","meowEnabled","meowNickname","meowMsgType","meowHtmlHeight","meowImgUrl","webExternal"];
var numberFields = {mergeBitrate:1, danmuFontSize:1, defaultBitrate:1, meowHtmlHeight:1};
// 用户改过、还没提交的字段。定时刷新会拿服务端的值填表单,不记下这些字段的话,
// 刚填好的数字 3 秒后会被刷回旧值,看起来就像「改了又自己恢复」
var dirty = {};
var lastSt = null; // 最近一次 /api/status 的结果,自动保存时用它填没提交的字段
function dirtyCount(){ return Object.keys(dirty).length; }
function markDirty(id){ if(id && !dirty[id]){ dirty[id] = true; syncDirtyUI(); } }
function dropDirty(id){ if(dirty[id]){ delete dirty[id]; syncDirtyUI(); } }
function syncDirtyUI(){
var n = dirtyCount();
var tip = document.getElementById("dirtyTip");
if(tip) tip.style.display = n ? "" : "none";
var b = document.getElementById("saveBtn");
if(b) b.classList.toggle("dirty", n > 0);
}
// 提交成功后才把对应的未保存标记清掉:值还是提交时那个才清,避免把提交期间的新改动也当成已保存
function clearSaved(body){
settingFields.forEach(function(id){
if(dirty[id] && String(fieldValue(id)).trim() === String(body[id]).trim()) delete dirty[id];
});
syncDirtyUI();
}
function fieldValue(id){
var el = document.getElementById(id);
if(!el) return "";
return el.type === "checkbox" ? !!el.checked : el.value;
}
// 按当前表单拼 /api/config 的请求体;serverForDirty 为真时,没提交的字段用服务端的值顶上
// (开关、下拉是改完立即保存的,不该被别的框里填了一半的内容连累到整批失败)
function settingsBody(serverForDirty){
var body = {};
settingFields.forEach(function(id){
var v = fieldValue(id);
if(typeof v === "string"){
v = v.trim();
if(numberFields[id]) v = parseInt(v, 10) || 0;
}
if(serverForDirty && dirty[id] && lastSt && lastSt[id] !== undefined) v = lastSt[id];
body[id] = v;
});
return body;
}
function setChk(id, v){ var el = document.getElementById(id); if(!saving && !dirty[id] && !el.matches(":focus")) el.checked = !!v; }
function setVal(id, v){ var el = document.getElementById(id); if(!saving && !dirty[id] && document.activeElement !== el) el.value = v; }
function show(el, v){ var e = document.getElementById(el); if(e) e.style.display = v ? "" : "none"; } function show(el, v){ var e = document.getElementById(el); if(e) e.style.display = v ? "" : "none"; }
// 管理接口都要带上口令 // 管理接口都要带上口令
function api(url, opt){ function api(url, opt){
@@ -510,6 +629,7 @@ async function refresh(){
if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; } if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
if(!r.ok) return; if(!r.ok) return;
var st = await r.json(); var st = await r.json();
lastSt = st;
hideLogin(); hideLogin();
// 口令只在登录后拉取一次,填进设置区的只读框 // 口令只在登录后拉取一次,填进设置区的只读框
if(!tokenLoaded){ if(!tokenLoaded){
@@ -523,6 +643,7 @@ async function refresh(){
setVal("directory", st.directory||""); setVal("directory", st.directory||"");
setVal("mergeOutputDir", st.mergeOutputDir||""); setVal("mergeOutputDir", st.mergeOutputDir||"");
setVal("mergeBitrate", st.mergeBitrate||0); setVal("mergeBitrate", st.mergeBitrate||0);
setVal("danmuFontSize", st.danmuFontSize||0);
setVal("defaultBitrate", st.defaultBitrate||0); setVal("defaultBitrate", st.defaultBitrate||0);
setVal("recordSubdir", st.recordSubdir||"record"); setVal("recordSubdir", st.recordSubdir||"record");
setVal("mergeSubdir", st.mergeSubdir||"merged"); setVal("mergeSubdir", st.mergeSubdir||"merged");
@@ -583,7 +704,17 @@ async function refresh(){
// 数据没变化不重建;正在列表里输入时也跳过,避免把没提交的输入清掉 // 数据没变化不重建;正在列表里输入时也跳过,避免把没提交的输入清掉
if(sig !== stSig && !list.contains(document.activeElement)){ if(sig !== stSig && !list.contains(document.activeElement)){
stSig = sig; stSig = sig;
// 重建前把用户改过、还没提交的码率记下来,建完再抄回去(填错的不用留)
var keepBr = {};
Array.prototype.forEach.call(list.querySelectorAll("input.br"), function(el){
var v = parseInt(el.value.trim(), 10);
if(el.dataset.dirty === "1" && !isNaN(v) && v >= 0) keepBr[el.dataset.uid] = el.value;
});
list.innerHTML = rows || '<div class="off">列表为空,在上面填 uid 添加</div>'; list.innerHTML = rows || '<div class="off">列表为空,在上面填 uid 添加</div>';
Object.keys(keepBr).forEach(function(uid){
var el = list.querySelector('input.br[data-uid="' + uid + '"]');
if(el){ el.value = keepBr[uid]; el.dataset.dirty = "1"; }
});
} }
var lr = await api("/api/log?since=" + logOffset); var lr = await api("/api/log?since=" + logOffset);
if(lr.status === 401){ showLogin("管理口令已失效,请重新输入"); return; } if(lr.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
@@ -615,12 +746,12 @@ function showMsg(ok, text){
setTimeout(function(){ if(d.parentNode) d.parentNode.removeChild(d); }, 220); setTimeout(function(){ if(d.parentNode) d.parentNode.removeChild(d); }, 220);
}, life); }, life);
} }
async function post(url, body){ async function post(url, body, errPrefix){
saving = true; saving = true;
try{ try{
var r = await api(url, {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)}); var r = await api(url, {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)});
if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return false; } if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return false; }
if(!r.ok) showMsg(false, await r.text()); if(!r.ok) showMsg(false, (errPrefix || "") + await r.text());
return r.ok; return r.ok;
}finally{ }finally{
saving = false; saving = false;
@@ -629,50 +760,52 @@ async function post(url, body){
loadFiles(); loadFiles();
} }
} }
async function saveSettings(b){ async function saveSettings(b, autoSave){
if(b) b.disabled = true; if(b) b.disabled = true;
var ok = await post("/api/config", { var body = settingsBody(!!autoSave);
directory: document.getElementById("directory").value.trim(), var ok = await post("/api/config", body, autoSave ? "保存失败,这次改动没生效:" : "");
autoMerge: document.getElementById("autoMerge").checked, if(ok){
mergeDelVideo: document.getElementById("mergeDelVideo").checked, clearSaved(body);
mergeDelDanmu: document.getElementById("mergeDelDanmu").checked, showMsg(true, "已保存");
mergeOutputDir: document.getElementById("mergeOutputDir").value.trim(), }
mergeBitrate: parseInt(document.getElementById("mergeBitrate").value, 10) || 0,
defaultBitrate: parseInt(document.getElementById("defaultBitrate").value, 10) || 0,
recordSubdir: document.getElementById("recordSubdir").value.trim(),
mergeSubdir: document.getElementById("mergeSubdir").value.trim(),
ffmpegPath: document.getElementById("ffmpegPath").value.trim(),
recordFormat: document.getElementById("recordFormat").value,
notifyEnabled: document.getElementById("notifyEnabled").checked,
notifyUrl: document.getElementById("notifyUrl").value.trim(),
notifyToken: document.getElementById("notifyToken").value.trim(),
notifyGroup: document.getElementById("notifyGroup").value.trim(),
notifyLiveOn: document.getElementById("notifyLiveOn").checked,
notifyLiveOff: document.getElementById("notifyLiveOff").checked,
notifyRecordStart: document.getElementById("notifyRecordStart").checked,
notifyMergeDone: document.getElementById("notifyMergeDone").checked,
meowEnabled: document.getElementById("meowEnabled").checked,
meowNickname: document.getElementById("meowNickname").value.trim(),
meowMsgType: document.getElementById("meowMsgType").value,
meowHtmlHeight: parseInt(document.getElementById("meowHtmlHeight").value, 10) || 0,
meowImgUrl: document.getElementById("meowImgUrl").value.trim(),
webExternal: document.getElementById("webExternal").checked
});
if(ok) showMsg(true, "已保存");
if(b) b.disabled = false; if(b) b.disabled = false;
} }
// 勾选类设置修改后立即保存,避免被定时刷新还原 // 勾选类设置修改后立即保存,避免被定时刷新还原
["autoMerge","mergeDelVideo","mergeDelDanmu","webExternal","notifyEnabled","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","meowEnabled"].forEach(function(id){ ["autoMerge","mergeDelVideo","mergeDelDanmu","webExternal","notifyEnabled","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","meowEnabled"].forEach(function(id){
document.getElementById(id).addEventListener("change", function(){ saveSettings(null); }); document.getElementById(id).addEventListener("change", function(){ dropDirty(id); saveSettings(null, true); });
}); });
// 下拉框改完也立即保存 // 下拉框改完也立即保存
document.getElementById("recordFormat").addEventListener("change", function(){ saveSettings(null); }); ["recordFormat","meowMsgType"].forEach(function(id){
document.getElementById(id).addEventListener("change", function(){ dropDirty(id); saveSettings(null, true); });
});
// HTML 高度只对 html 类型有意义,选 text 时收起来 // HTML 高度只对 html 类型有意义,选 text 时收起来
function toggleMeowHeight(){ function toggleMeowHeight(){
var isHTML = document.getElementById("meowMsgType").value === "html"; var isHTML = document.getElementById("meowMsgType").value === "html";
document.getElementById("meowHeightRow").style.display = isHTML ? "" : "none"; document.getElementById("meowHeightRow").style.display = isHTML ? "" : "none";
} }
document.getElementById("meowMsgType").addEventListener("change", function(){ toggleMeowHeight(); saveSettings(null); }); document.getElementById("meowMsgType").addEventListener("change", toggleMeowHeight);
// 文本类字段一改就记成「未保存」,定时刷新不再盖掉它,直到保存成功
document.addEventListener("input", function(e){
var el = e.target;
if(!el) return;
if(el.classList && el.classList.contains("br")){ el.dataset.dirty = "1"; return; }
if(el.id && settingFields.indexOf(el.id) >= 0) markDirty(el.id);
});
// 回车等于点保存:输入框不在 form 里,浏览器默认不会提交,很容易以为已经生效了
document.addEventListener("keydown", function(e){
if(e.key !== "Enter" || e.altKey || e.ctrlKey || e.metaKey || e.isComposing) return;
var el = e.target;
if(!el || el.tagName !== "INPUT" || el.type !== "text" || el.readOnly) return;
if(el.classList.contains("br")){ el.blur(); return; } // 码率失焦即提交
if(el.id === "newuid"){ e.preventDefault(); addUid(); return; }
if(el.id === "authTokenNew"){ e.preventDefault(); changeToken(); return; }
if(el.id === "mrgOffset" || el.id === "mrgOut" || el.id === "mrgDanmu"){ e.preventDefault(); checkMerge(); return; }
if(settingFields.indexOf(el.id) >= 0){ e.preventDefault(); saveSettings(null); }
});
// 还有没保存的改动时提醒一句,别直接关掉页面
window.addEventListener("beforeunload", function(e){
if(dirtyCount() > 0){ e.preventDefault(); e.returnValue = ""; return ""; }
});
var fileList = []; var fileList = [];
function fmtSize(n){ function fmtSize(n){
if(!n) return "0 B"; if(!n) return "0 B";
@@ -699,6 +832,7 @@ async function loadFiles(b){
if(!r.ok){ showMsg(false, await r.text()); return; } if(!r.ok){ showMsg(false, await r.text()); return; }
var d = await r.json(); var d = await r.json();
fileList = d.files || []; fileList = d.files || [];
recDir = d.recordDir || "";
fillMergePickers(); fillMergePickers();
document.getElementById("diskInfo").textContent = document.getElementById("diskInfo").textContent =
"磁盘剩余 " + fmtSize(d.free) + " / 共 " + fmtSize(d.total) + "磁盘剩余 " + fmtSize(d.free) + " / 共 " + fmtSize(d.total) +
@@ -735,6 +869,192 @@ async function delFile(i){
showMsg(!!(r && r.ok), (r && r.ok) ? "已删除" : ("删除失败:" + (r ? await r.text() : "连接失败"))); showMsg(!!(r && r.ok), (r && r.ok) ? "已删除" : ("删除失败:" + (r ? await r.text() : "连接失败")));
loadFiles(); loadFiles();
} }
// ========== 路径选择器 ==========
// 目录 / 文件框两种填法并存:自己敲或粘贴,或者点「浏览…」逐级点选
var pickField = ""; // 选中的路径填回哪个输入框
var pickKind = "dir"; // dir 选文件夹,file 选文件
var pickCur = ""; // 当前所在目录
var pickParent = ""; // 上一级
var pickFile = ""; // 单选时选中的那个
var pickMulti = false; // 多选模式(视频框这种一行一个、可以选多个的)
var pickSel = []; // 多选已勾选的绝对路径,换目录也不清,所以能跨文件夹/跨盘攒
var recDir = ""; // 录播目录,挑弹幕和视频时从它开始
var lastPickKey = "", lastPickAt = 0; // 列表里连点同一项只算一次
var lastNavAt = 0; // 刚跳转过目录的这段时间里忽略列表上的点击,双击文件夹才不会连进两层
function openPick(id, kind, multi){
var el = document.getElementById(id);
pickField = id;
pickKind = kind === "file" ? "file" : "dir";
pickMulti = !!multi && pickKind === "file";
pickFile = "";
pickSel = [];
document.getElementById("pickTitle").textContent = pickMulti ? "选择文件(可多选)" : (pickKind === "file" ? "选择文件" : "选择文件夹");
document.getElementById("pickDesc").textContent = pickMulti
? "点一下文件名即勾选,再点一下取消;可以进到别的文件夹继续勾。选好后点下面的按钮追加到框里,已经填好的内容不会被清掉。"
: "逐级点选即可;也可以直接在地址栏里填或粘贴路径后按回车。选好后点下面的按钮填进设置框。";
document.getElementById("pickHidden").checked = false;
document.getElementById("pick").classList.toggle("multi", pickMulti);
document.getElementById("pick").classList.add("on");
// 多行框(视频)取第一行当起点,整段拿去当路径会解析出一个乱七八糟的目录
var start = el ? el.value.split("\n")[0].trim() : "";
// 框里是文件名而不是路径时(弹幕名、视频名这种)不算路径:这两个框从录播目录开始,其它从程序所在目录开始
if(start.indexOf("/") < 0 && start.indexOf("\\") < 0) start = (id === "mrgDanmu" || id === "mrgVideos") ? recDir : "";
pickGo(start);
}
function closePick(){ document.getElementById("pick").classList.remove("on"); }
function pickUp(){ if(pickParent) pickGo(pickParent); }
async function pickGo(path){
lastNavAt = Date.now();
var msg = document.getElementById("pickMsg");
msg.textContent = "";
var base = "/api/browse?kind=" + pickKind + (document.getElementById("pickHidden").checked ? "&hidden=1" : "");
var d = null, fellBack = false;
// 框里填的是文件、或者目录根本不存在时,往上退到能打开的一层,别停在空列表上
for(var i = 0; i < 6; i++){
var url = base + (path ? "&path=" + encodeURIComponent(path) : "");
var r = null;
try{ r = await api(url); }catch(e){}
if(!r){ msg.textContent = "读取失败,连接不上服务器"; return; }
if(r.status === 401){ closePick(); showLogin("管理口令已失效,请重新输入"); return; }
if(!r.ok){ msg.textContent = "读取失败:" + await r.text(); return; }
d = await r.json();
if(!d.error || !d.parent) break;
path = d.parent;
fellBack = true;
}
var items = d.entries || [];
pickCur = d.path || "";
pickParent = d.parent || "";
// 单选时换了目录、之前选中的文件就不在当前视图里了,清掉;多选是故意跨目录攒的,不动
if(!pickMulti && pickFile && !items.some(function(x){ return x.path === pickFile; })) pickFile = "";
document.getElementById("pickPath").value = pickCur;
document.getElementById("pickUp").disabled = !pickParent;
document.getElementById("pickRoots").innerHTML = (d.roots || []).map(function(x){
return '<button type="button" data-path="' + esc(x.path) + '">' + esc(x.name) + '</button>';
}).join("");
var list = document.getElementById("pickList");
if(!items.length){
list.innerHTML = '<div class="off">' + (pickKind === "file" ? "这里没有可选项" : "这里没有子文件夹") + '</div>';
}else{
list.innerHTML = items.map(function(x){
var on = "";
if(!x.dir){
on = (pickMulti ? pickSel.indexOf(x.path) >= 0 : x.path === pickFile) ? " on" : "";
}
return '<div class="pi' + (x.dir ? " pi-dir" : "") + on + '" data-path="' + esc(x.path) + '" data-dir="' + (x.dir ? "1" : "0") + '">'
+ '<span class="pi-c"></span>'
+ '<span class="pi-n" title="' + esc(x.path) + '">' + esc(x.name) + '</span>'
+ '<span class="pi-m">' + (x.dir ? "文件夹" : fmtSize(x.size)) + '</span></div>';
}).join("");
}
updatePickBar();
if(d.error){
msg.textContent = d.error;
}else if(fellBack){
msg.textContent = "原来填的位置打不开,已经跳到能打开的上级目录";
}else if(d.truncated){
msg.textContent = "条目太多,只列出了前 2000 项";
}
}
// 按钮文案和「已选 N 项」都跟着当前模式与勾选情况走
function updatePickBar(){
var ok = document.getElementById("pickOk");
var sel = document.getElementById("pickSel");
if(pickMulti){
ok.disabled = !pickSel.length;
ok.textContent = pickSel.length ? ("选用这 " + pickSel.length + " 个文件") : "选用选中的文件";
sel.innerHTML = pickSel.length
? ("已选 " + pickSel.length + " 项(<span class=\"lnk\" onclick=\"pickClearSel()\">清空</span>)")
: "点文件名即勾选,可换目录继续勾";
}else{
ok.disabled = pickKind === "file" && !pickFile;
ok.textContent = pickKind === "file" ? "选用这个文件" : "选用这个文件夹";
sel.innerHTML = "";
}
}
// 只重画高亮,不重新拉目录
function paintPickSel(){
var all = document.querySelectorAll("#pickList .pi");
for(var i = 0; i < all.length; i++){
var it = all[i];
if(it.dataset.dir === "1") continue;
it.classList.toggle("on", pickMulti ? pickSel.indexOf(it.dataset.path) >= 0 : it.dataset.path === pickFile);
}
updatePickBar();
}
function pickClearSel(){ pickSel = []; paintPickSel(); }
// 录播目录下的文件就只填文件名,框里清爽,保存目录以后改了也还认得;别处的填绝对路径
function pickFillValue(p){
if(!recDir) return p;
var parent = p.replace(/[\\/][^\\/]*$/, "");
if(parent.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase() === recDir.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase()){
return p.split(/[\\/]/).pop();
}
return p;
}
function pickConfirm(){
var el = document.getElementById(pickField);
if(pickMulti){
if(!pickSel.length) return;
if(el){
// 追加:已有行原样留着,只把这次勾的加进去(同名/同路径不重复)
var lines = el.value.split("\n").map(function(s){ return s.trim(); }).filter(function(s){ return s; });
pickSel.forEach(function(p){
var name = pickFillValue(p);
if(lines.indexOf(name) < 0 && lines.indexOf(p) < 0) lines.push(name);
});
el.value = lines.join("\n");
// 手动合并的视频/弹幕框不进配置,标了「未保存」也永远清不掉,所以只对设置表单里的框标记
if(settingFields.indexOf(pickField) >= 0) markDirty(pickField);
}
closePick();
return;
}
var v = pickKind === "file" ? pickFile : pickCur;
if(!v) return;
if(pickKind === "file") v = pickFillValue(v);
if(el){
el.value = v;
if(settingFields.indexOf(pickField) >= 0) markDirty(pickField);
}
closePick();
}
document.getElementById("pickList").addEventListener("click", function(e){
var it = e.target.closest(".pi");
if(!it) return;
if(Date.now() - lastNavAt < 350) return;
// 双击时浏览器会先发两次 click,同一条目短时间内重复点击只算一次
if(it.dataset.path === lastPickKey && Date.now() - lastPickAt < 350) return;
lastPickKey = it.dataset.path;
lastPickAt = Date.now();
if(it.dataset.dir === "1"){ pickGo(it.dataset.path); return; }
if(pickMulti){
var i = pickSel.indexOf(it.dataset.path);
if(i >= 0) pickSel.splice(i, 1); else pickSel.push(it.dataset.path);
}else{
pickFile = it.dataset.path;
}
paintPickSel();
});
// 文件双击直接选用(多选时把双击的这个也算上,再连同已勾的一起追加)
document.getElementById("pickList").addEventListener("dblclick", function(e){
var it = e.target.closest(".pi");
if(!it || it.dataset.dir === "1") return;
if(pickMulti && pickSel.indexOf(it.dataset.path) < 0) pickSel.push(it.dataset.path);
pickConfirm();
});
document.getElementById("pickRoots").addEventListener("click", function(e){
var b = e.target.closest("button");
if(b) pickGo(b.dataset.path);
});
document.getElementById("pickHidden").addEventListener("change", function(){ pickGo(pickCur); });
document.getElementById("pickPath").addEventListener("keydown", function(e){
if(e.key === "Enter"){ e.preventDefault(); pickGo(this.value.trim()); }
});
document.addEventListener("keydown", function(e){
if(e.key === "Escape" && document.getElementById("pick").classList.contains("on")) closePick();
});
// ========== 手动合并 ========== // ========== 手动合并 ==========
var mrgBusy = false; // 是否正在合并,跟着 /api/status 一起刷新 var mrgBusy = false; // 是否正在合并,跟着 /api/status 一起刷新
function mrgPayload(){ function mrgPayload(){
@@ -879,24 +1199,34 @@ async function delUid(uid){
if(!confirm("确定删除 uid " + uid + " 并停止其录制?")) return; if(!confirm("确定删除 uid " + uid + " 并停止其录制?")) return;
post("/api/streamer/remove", {uid: uid}); post("/api/streamer/remove", {uid: uid});
} }
document.addEventListener("change", function(e){ // 码率填错时把框里的内容恢复成服务端记录的值,不能干等下一次列表重建
function restoreBr(el){
var uid = parseInt(el.dataset.uid, 10);
var cur = (lastSt && lastSt.streamers ? lastSt.streamers : []).filter(function(s){ return s.uid === uid; })[0];
delete el.dataset.dirty;
el.value = cur ? (cur.bitrate || 0) : 0;
}
document.addEventListener("change", async function(e){
var t = e.target; var t = e.target;
if(t.matches('input[type=checkbox][data-uid]')){ if(t.matches('input[type=checkbox][data-uid]')){
var uid = parseInt(t.dataset.uid, 10); var uid = parseInt(t.dataset.uid, 10);
var row = t.closest(".row"); var row = t.closest(".row");
var danmu = row.querySelector('input[data-k=danmu]').checked; var danmu = row.querySelector('input[data-k=danmu]').checked;
var record = row.querySelector('input[data-k=record]').checked; var record = row.querySelector('input[data-k=record]').checked;
var bitrate = parseInt(row.querySelector('input.br').value, 10) || 0; var brEl = row.querySelector('input.br');
post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate}); var bitrate = parseInt(brEl.value, 10) || 0;
// 勾选时会把同一行的码率一起提交,提交成功后就不算「未提交的输入」了
if(await post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate})) delete brEl.dataset.dirty;
} }
if(t.matches('input.br')){ if(t.matches('input.br')){
var uid2 = parseInt(t.dataset.uid, 10); var uid2 = parseInt(t.dataset.uid, 10);
var row2 = t.closest(".row"); var row2 = t.closest(".row");
var v = parseInt(t.value.trim(), 10); var v = parseInt(t.value.trim(), 10);
if(isNaN(v) || v < 0){ showMsg(false, "码率必须是大于等于 0 的数字"); refresh(); return; } if(isNaN(v) || v < 0){ showMsg(false, "码率必须是大于等于 0 的数字"); restoreBr(t); return; }
var danmu2 = row2.querySelector('input[data-k=danmu]').checked; var danmu2 = row2.querySelector('input[data-k=danmu]').checked;
var record2 = row2.querySelector('input[data-k=record]').checked; var record2 = row2.querySelector('input[data-k=record]').checked;
post("/api/streamer", {uid: uid2, danmu: danmu2, record: record2, bitrate: v}); // 提交成功才能把「未提交」标记去掉,列表重建时才不会把它当成用户还在填
if(await post("/api/streamer", {uid: uid2, danmu: danmu2, record: record2, bitrate: v})) delete t.dataset.dirty;
} }
}); });
// 回到地址栏或上次看的那个分区 // 回到地址栏或上次看的那个分区
@@ -933,6 +1263,7 @@ type webStatus struct {
MergeDelDanmu bool `json:"mergeDelDanmu"` MergeDelDanmu bool `json:"mergeDelDanmu"`
MergeOutputDir string `json:"mergeOutputDir"` MergeOutputDir string `json:"mergeOutputDir"`
MergeBitrate int `json:"mergeBitrate"` MergeBitrate int `json:"mergeBitrate"`
DanmuFontSize int `json:"danmuFontSize"`
DefaultBitrate int `json:"defaultBitrate"` DefaultBitrate int `json:"defaultBitrate"`
WebExternal bool `json:"webExternal"` WebExternal bool `json:"webExternal"`
RecordSubdir string `json:"recordSubdir"` RecordSubdir string `json:"recordSubdir"`
@@ -993,6 +1324,7 @@ func statusHandler(w http.ResponseWriter, r *http.Request) {
MergeDelDanmu: config.MergeDelDanmu, MergeDelDanmu: config.MergeDelDanmu,
MergeOutputDir: config.MergeOutputDir, MergeOutputDir: config.MergeOutputDir,
MergeBitrate: config.MergeBitrate, MergeBitrate: config.MergeBitrate,
DanmuFontSize: config.DanmuFontSize,
WebExternal: config.WebExternal, WebExternal: config.WebExternal,
RecordSubdir: config.RecordSubdir, RecordSubdir: config.RecordSubdir,
MergeSubdir: config.MergeSubdir, MergeSubdir: config.MergeSubdir,
@@ -1070,6 +1402,7 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
MergeDelDanmu bool `json:"mergeDelDanmu"` MergeDelDanmu bool `json:"mergeDelDanmu"`
MergeOutputDir string `json:"mergeOutputDir"` MergeOutputDir string `json:"mergeOutputDir"`
MergeBitrate int `json:"mergeBitrate"` MergeBitrate int `json:"mergeBitrate"`
DanmuFontSize int `json:"danmuFontSize"`
DefaultBitrate int `json:"defaultBitrate"` DefaultBitrate int `json:"defaultBitrate"`
WebExternal bool `json:"webExternal"` WebExternal bool `json:"webExternal"`
RecordSubdir string `json:"recordSubdir"` RecordSubdir string `json:"recordSubdir"`
@@ -1107,6 +1440,10 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "mergeBitrate 不能小于 0", http.StatusBadRequest) http.Error(w, "mergeBitrate 不能小于 0", http.StatusBadRequest)
return return
} }
if req.DanmuFontSize < 0 {
http.Error(w, "danmuFontSize 不能小于 0(0 表示跟随弹幕文件)", http.StatusBadRequest)
return
}
if req.DefaultBitrate < 0 { if req.DefaultBitrate < 0 {
http.Error(w, "defaultBitrate 不能小于 0", http.StatusBadRequest) http.Error(w, "defaultBitrate 不能小于 0", http.StatusBadRequest)
return return
@@ -1175,6 +1512,7 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
config.MergeDelDanmu = req.MergeDelDanmu config.MergeDelDanmu = req.MergeDelDanmu
config.MergeOutputDir = req.MergeOutputDir config.MergeOutputDir = req.MergeOutputDir
config.MergeBitrate = req.MergeBitrate config.MergeBitrate = req.MergeBitrate
config.DanmuFontSize = req.DanmuFontSize
config.DefaultBitrate = req.DefaultBitrate config.DefaultBitrate = req.DefaultBitrate
restartWeb := config.WebExternal != req.WebExternal restartWeb := config.WebExternal != req.WebExternal
config.WebExternal = req.WebExternal config.WebExternal = req.WebExternal
@@ -1499,6 +1837,7 @@ func startWebUI(ctx context.Context) {
mux.HandleFunc("GET /api/log", logHandler) mux.HandleFunc("GET /api/log", logHandler)
mux.HandleFunc("POST /api/notify/test", notifyTestHandler) mux.HandleFunc("POST /api/notify/test", notifyTestHandler)
mux.HandleFunc("GET /api/files", filesHandler) mux.HandleFunc("GET /api/files", filesHandler)
mux.HandleFunc("GET /api/browse", browseHandler)
mux.HandleFunc("GET /api/file", fileDownloadHandler) mux.HandleFunc("GET /api/file", fileDownloadHandler)
mux.HandleFunc("POST /api/file/remove", fileRemoveHandler) mux.HandleFunc("POST /api/file/remove", fileRemoveHandler)
mux.HandleFunc("POST /api/merge/info", mergeInfoHandler) mux.HandleFunc("POST /api/merge/info", mergeInfoHandler)