tamakyi
|
c26eb4c648
|
合并任务排队串行,不再互相顶掉进度和杀错 ffmpeg
审查发现:全局只有一份任务进度状态,但没有任何东西拦住两个合并同时跑。
1. 为什么必须串行
jobStart() 原来是无条件重置全部字段的。多个主播同时下播(很常见),
或者自动合并跑着的时候在页面上点一次手动合并,就会有两个任务同时跑:
后启动的把前一个的输出名、进度、已渲染时长全部清零;更糟的是 jobStop()
只认最后注册的那个 ffmpeg,点「停止」杀的是别人家任务的进程,
而自己那个继续跑;两个任务还会互相读到 stopped 标记而提前退出。
界面上看只是一个进度条乱跳,背后是合并结果可能张冠李戴。
2. 怎么串起来的
加一把 mergeSlot 锁占任务位:
- 手动合并用 TryLock,抢不到直接 409 并告诉用户现在谁在跑 —— 它是一次
HTTP 请求,不能让人干等几十分钟。
- 自动合并用 Lock 排队等待,它本来就是后台任务,丢掉白录一场。
jobStart 改成返回 bool:已经有任务在跑时返回 false 且一个字段都不动。
这样即使以后有调用方忘了先拿任务位,也只是失败而不会互相覆盖。
3. 顺带修「panic 之后卡片一直转」
原来 jobFinish() 写在 autoMerge / mergeHandler 的正常返回路径上,任务内部
一旦 panic(外部 safeGo 的 recover 兜在外层)它就永远不会执行,任务会一直
停在 active,界面上的进度条再也不动,ffmpeg 也失联了。
现在统一走 runMergeExclusive():任务位用 defer 释放,panic 翻译成 error,
保证 jobFinish 一定能跑到。
4. 之前 ban 掉的 jobActive() 接回来了
它本来就该是这道护栏,写了却没人调用,现在用于判断要不要提示「在排队」
以及拒绝新任务时报出正在忙的任务名。jobState 的锁改成 RWMutex 供它读。
已验证:
- 新增 4 个用例:TestJobStartRejectsWhenBusy(被拒时 Must 一个字段都不改,
包括进度必须还是原任务的)、TestMergeSlotTryLockExclusive、
TestMergeSlotSerializes(4 个并发任务必须全跑完且不重不漏)、
TestMergeExclusiveReleasesOnPanic(panic 后必须拿到任务位 + 翻出错误)。
- 反向验证:把 jobStart 的占用判断临时改成 `if false && job.active`,
TestJobStartRejectsWhenBusy 立刻报「应当返回 false」。
- 现有测试跟着走真实链路(progress_test 加了 startJobForTest 统一先收尾、
manualmerge_test 走 execManualMerge 独占执行)—— 改之前这些用例单独跑过、
连着跑会失败,正是因为前一个用例把任务状态留在了 active。
- gofmt / go vet / go build / GOOS=linux go build / go test / go test -race 全过。
|
2026-09-26 02:41:29 +08:00 |
|
tamakyi
|
7dd2487cb8
|
合并任务的可视化进度、预计剩余时间与暂停/停止
用户反馈:合并的进度只体现在日志里(ffmpeg 的 frame=/speed= 行),看不出
百分比、当前环节和还要多久,也没有办法中途暂停或放弃。
1. 新增全局任务状态(progress.go)
一份状态同时服务自动合并和手动合并,记录阶段、总进度、速度、帧率、
已处理时长、输入/预计输出/已写出体积、ETA 和速度折线采样点。
总进度按阶段加权:准备 0.02 / 分段拼接 0.08 / 获取头像 0.10 /
画面渲染 0.75 / 拼接与落盘 0.05,渲染阶段按「已渲染 ÷ 总时长」折算。
ETA 在渲染阶段用「剩余时长 ÷ 倍速」,其它阶段按已用时间线性外推。
2. 用 ffmpeg 的 -progress pipe:1 取结构化进度
runFFmpeg 拆出带回调的版本:解析 out_time_us / speed / fps / total_size,
分块渲染时把块内时间折算到整片时间轴,并把已完成块的字节数累计进来。
ffmpeg 自己的统计行仍在 stderr,单独用命令行时不会少东西。
预计成片体积先按「(视频码率 + 音频 192k)× 时长 ÷ 8」估,渲染过 5%
之后换成「已写出字节 ÷ 已完成比例」外推(estimateAudioKbps)。
3. 暂停 / 停止
Windows 用 ntdll 的 NtSuspendProcess / NtResumeProcess(照任务管理器
的「挂起」语义),Linux 用 SIGSTOP / SIGCONT,放在带 build tag 的
jobctl_windows.go / jobctl_unix.go 里。
非 ffmpeg 环节(下载头像、逐块渲染之间)用 jobGate 闸门生效。
停止 = 杀掉当前 ffmpeg 并置位,各层返回 errJobStopped:不再回退其它
编码器、不落成片、清掉分块临时文件,任务以「已停止」结束(而不是失败)。
4. 接口
GET /api/job 给页面轮询任务快照;POST /api/job/control 接受
pause / resume / stop,没有任务时 409、未知动作 400。
5. 界面
监控分区顶部新增「转换任务」卡片:阶段步骤条、总进度条、实时指标、
预计剩余时间、源片与预计成片的体积对比条、渲染速度折线(每秒一个点、
最多两分钟),以及暂停/继续/停止按钮;页面最顶上再加一条全局细进度条,
切到哪个分区都看得到。有任务时每秒轮询,空闲时 4 秒。任务结束后卡片
再停留 20 秒显示结果。
已验证:
- 新增 progress_test.go 12 个用例:阶段权重和为 1、进度按阶段折算、阶段
不回退、ETA、体积预估与实测外推的切换、折线采样、暂停/停止的闸门语义、
控制接口在无任务时报错、speed 字段与 -progress 文本的解析。
测试里把 errJobStopped 和真失败区分开(jobErrText)。
- 端到端(独立实例 51897 + 独立 config,全程没碰仓库里的实例和
acfunlive.exe):跑通完整合并,进度到 100%、预计体积与成片实际大小
相差 0.4%;暂停后进度冻结且 ffmpeg 的 CPU 时间 5 秒内 0 增长;
继续后进度恢复;停止后任务以「已停止」结束、没有半成品成片、
没有残留临时文件、没有残留 ffmpeg 进程。
- 界面(playwright 驱动系统 Edge):步骤条 5 个阶段且只有一个处于进行中、
进度条与顶部细条同步、5 项指标(速度/帧率/已处理时长/已用时间/已写出)、
ETA 文案、体积对比条两段宽度按比例、折线有采样点、界面按钮能暂停/继续/
停止、结束后按钮隐藏且文案是「已停止」、深色主题与 390 宽移动端无横向溢出。
- 过程中修掉三个真缺陷:停止时残留分块临时文件(part 登记晚于渲染)、
界面「继续」按钮点不动(onclick 写死了 pause)、停止被当成失败报红
(现在按 message 区分 已完成/已停止/失败)。
- gofmt / go vet / go build / GOOS=linux go build / go test 全过。
|
2026-09-26 02:05:47 +08:00 |
|
tamakyi
|
cbe5dfa982
|
弹幕字号按画面短边自动定,竖屏成片不再字大到铺满整行
1. 字号基准从「画面高度」改成「画面短边」
以前合并时只用高度比换算字号(字号 × 视频高 ÷ 弹幕文件高)。横屏弹幕文件
(1920x1080、60 号)配竖屏视频(720x1280)时会被放大到 71 号,而画面只有
720 宽,一条 10 字弹幕就铺满整行。横竖屏互换时高度不是约束文字宽度的维度,
短边才是:横屏取高、竖屏取宽。
2. 「弹幕字号」填 0 的含义从「跟随弹幕文件」改成「自动按画面分辨率」
取画布短边的 5.56%(60/1080)。于是 1920x1080 → 60、1280x720 → 40、
960x540 → 30,横屏行为与以前完全一致;720x1280 → 40(以前 60)、
640x1280 → 36。填大于 0 仍是精确像素覆盖。
3. 录制端竖屏档位预设 720x1280 的字号从 60 改成 40
原来横屏三档都是短边的 5.56%,只有竖屏这档是 8.33%,比横屏大 50%。
改完四档口径一致,新录的弹幕文件和成片也对得上。
4. 坐标系和字号拆开
unifyDanmuSpace 只负责把 PlayRes 统一到视频分辨率,字号统一交给
applyFontSize 按最终画布算,避免两处各改一次。
5. .gitignore 加上 /avatar_cache
头像缓存挪到程序目录后,程序正好跑在仓库里,缓存目录会被 git 看见,
git add . 会把那 336 个 PNG 一起提交进去。
已验证:
- 新增 TestShortSide / TestAutoFontSize / TestSubConfigsMatchAutoFontSize /
TestApplyFontSize;TestUnifyDanmuSpace 改成只校验 PlayRes,并且新增断言
确认它不再动字号。
- 反向验证:把短边规则临时退回「按高度」,TestAutoFontSize 报
「720x1280 应为 40,实际 71」「1080x1920 应为 60,实际 107」,
TestApplyFontSize、TestSubConfigsMatchAutoFontSize 同步失败。
- 端到端:造 720x1280 竖屏视频 + 1920x1080/60 号的 jsonl,字号填 0 真跑一次合并,
日志打出「竖屏画布 720x1280,弹幕字号自动取 40」;同一输入再用旧规则跑一次对照,
抽同一时刻的帧量墨迹高度:改前 46px、改后 25px(1.84 倍)。
- gofmt / go vet / go build / GOOS=linux go build / go test 全过。
|
2026-09-26 00:59:17 +08:00 |
|
tamakyi
|
aa47dd342d
|
修:选了「自定义」却看不到码率框(焦点还在下拉上,列表不重建)
页面为了让定时刷新不冲掉用户正在填的内容,列表在「焦点还在列表里」时不重建。
但原生 select 选完一项后焦点会留在下拉上,于是选了「自定义」以后列表一直不重建,
那个码率框要等用户点到别处才出现 —— 看起来就是「选了自定义根本没地方填」。
- 选到「自定义」时当场把码率框建出来并聚焦(不用等服务端回来和下一次重建),
切走时当场收掉。
- 顺带把「不重建」的条件收紧成「只有正在列表里的文本框里打字时才跳过」:
下拉和勾选都是改完立即提交的,之前它们也会一直卡住重建,
连带「录播中/弹幕中」的徽标也要点到别处才刷新。
- 框里的初始值:填过自定义就用上次那个数(切走再切回来不会被改成当前档位),
没填过才用现在实际在用的那一档。
验证方式也换了:回归脚本里改用「点开下拉 + 键盘选最后一项」这种真实操作
(Playwright 的 select_option 不保留焦点,正是它让这个 bug 漏了过去),
新增「选完立刻就有框」「焦点直接落在框里」「切走即收掉」「切回带着上次的值」四条断言;
verify_streamer_br.py 33 项、verify_qualities.py 全过,verify_settings.py /
verify_danmu_match.py 无回归,go test ./...、gofmt、go vet 干净。
|
2026-09-25 21:14:59 +08:00 |
|
tamakyi
|
237fffa8fe
|
录制档位改成「最高/第二高/最低/自定义」四种相对偏好,没开播也能先设
上一版的下拉里列的是「这次读到的实际档位」,没读到档位表(没开播)时下拉里就只剩
一项,想预先设「我要最高档」或「最低档」都做不到。改成按「实际档位里的第几档」来选:
- 新增 `qualityPref`:top(最高档)/ second(第二高,默认)/ lowest(最低档)/
custom(取不超过给定码率的最高档,都比它高时退回最低档)。四种偏好任何时候都
在列表里,开播后自动对应到实际档位;读到档位表后把对应结果标在选项后面
(`第二高(蓝光 4M)`、`自定义(≤2500K = 高清)`)。
- `bitrate` 只在 custom 时有意义。`resolveQualityBitrate(tiers, pref, custom)` 统一
解析:相对偏好没有档位表时返回 0(取流退回最高档),custom 返回填的上限。
解析只在取流时做,所以主播换了档位表,偏好会跟着一起变。
- 老配置兼容:没有 qualityPref 时,`bitrate > 0` 当成 custom(就是原来的「码率上限」,
行为不变),`bitrate == 0` 当成默认的第二高;在 checkConfig 里补齐(不写盘,下次
保存自然带上)。`setBitrate` 相应换成 `setQuality(uid, pref, custom)`。
- 页面:选 custom 时旁边出现码率框,初始值就是**当前实际在用的那一档**(不会一切过去
就掉到最低档),回车或失焦即提交,填错给提示并回填服务端的值;从 custom 切到别的
档位不带 bitrate,服务端保留原值,改回来还是原来那个数。
- 设置区同步:新主播的默认档位也用同一套四项(`defaultQualityPref`),只有选自定义
才露出码率上限框(切过去会补一个能用的 3000,免得新主播默默用最低档)。
顺带修一个脚本抓到的真 bug:档位名传成了档位对象(`esc()` 里会 `.replace`),只要
档位表非空就会抛异常,而 refresh() 整段包在 try/catch 里 —— 表现是整个主播列表
变成空白且没有任何报错。现在取的是 `qs[i].name`。
测试:qualities_test.go 重写 TestResolveQualityBitrate(10 种偏好/码率组合)并新增
TestMigrateQualityPref、TestUpgradeQualityPrefs、TestSetQuality;verify_streamer_br.py
改成带迷你后端的假服务(POST 会真正落到假状态上),30 项断言全过;verify_qualities.py
25 项(含设置区与 config.json 往返)全过;verify_settings.py / verify_pick.py /
verify_danmu_match.py 无回归;go test ./...、gofmt、go vet 干净。
|
2026-09-25 20:42:22 +08:00 |
|
tamakyi
|
7b853c1a22
|
档位下拉:指定过的码率不在档位表里时,文案不再重复括号
老配置里常见的是 bitrate 5000 这种「码率上限」,它不是任何档位名,之前会渲染成
「已指定:5000K(5000K)」。现在写成「已指定:≤5000K」,读到档位表后带上档位名
(「已指定:≤5000K(蓝光 4M)」),和 >0 时「取不超过它的最高档」的语义也对得上。
|
2026-09-25 20:05:53 +08:00 |
|
tamakyi
|
348e3bd675
|
档位改成「自动·第二高 + 实际档位」的下拉;手动合并也推送;头像缓存挪到程序目录
三件事:
1) 主播列表的录制档位
- 一律是下拉,不再有手填输入框。第一项「自动 · 第二高」(值 0)= 按这次读到的
档位排序自上往下取第二高(只有一档就用它,读不到档位表才退回最高档);
后面依次列出实际档位。选最高/最低/中间某一档直接点它,手动选过之后
「自动」仍留在列表里,随时能改回去。
- 语义变化:0 不再被写死成某个具体码率。原来记录档位时会把「没选过」的主播
按第二档定下来(写进 config.json),现在只在取流时解析,所以主播换了档位表
也能跟着走;app 启动时的补齐逻辑(applyCachedDefaultQualities)随之去掉。
- 解析规则统一到 resolveQualityBitrate:0 = 第二高,>0 = 不超过它的最高档,
都比它高时退回最低档;「自动」项会把解析到的档位名带出来
(自动 · 第二高(当前 = 蓝光 4M))。/api/status 和档位接口都多了 qualityBitrate。
- 点「档位」按钮时先给一句「正在读取码率档位…」:主播没开播时服务端要重试三次
才放弃,约 5 秒,之前这段时间界面没有任何反应。
2) 手动合并完成也推送(这就是「MeoW 只在测试时成功」的原因)
- 原来只有自动合并调 sendNotify,手动合并从头到尾没推过,所以事件开着、渠道也通
却一直收不到。现在手动合并完成后同样按 mergeDone 事件推送两个渠道。
- 手动合并没有 uid,会拿成片名(再退到视频名)去对已有主播名,只有一个能对上时
才在文案里带上主播名,对不上就用「手动合并完成」。
3) 头像缓存挪到程序所在目录
- 原来存在「录播目录/avatar_cache」,换一次保存目录(甚至换盘)就要把同一批观众
的头像重新下一遍(一个头像可能 0.5MB)。现在统一放程序目录下的 avatar_cache。
- 旧位置作为只读回退:命中就搬过来(同盘改名,跨盘退回复制),不重复下载;
没被用到的旧文件留在原地,用到哪个搬哪个。
验证:
- 新增 avatar_test.go 两个用例(缓存路径、旧位置搬家)和 notify_test.go 的
TestManualMergeSendsMergeDoneNotify(真跑一次合并,断言 webhook 与 MeoW 各收到
一条 mergeDone,且从文件名认出主播);qualities_test.go 改成新语义,新增
resolveQualityBitrate(7 种档位/码率组合)和 guessUIDByFileName。
- 端到端:把旧位置的头像缓存放在录播目录、头像地址指向连不通的端口,合并仍然成功
且画面里有那张头像(纯红像素 400 个),证明是搬来的而不是重新下载的;文件也从
录播目录搬到了程序目录。
- 浏览器:verify_streamer_br.py 重写成档位下拉的 20 项断言,verify_qualities.py
更新为新语义,两个都全过;verify_settings.py / verify_pick.py / verify_danmu_match.py
无回归;go test ./...、gofmt、go vet 干净。
|
2026-09-25 20:03:24 +08:00 |
|
tamakyi
|
eda83dd1e3
|
主播列表的码率改为直播实际档位下拉,没选过时默认第二档
原来是一个手填的码率上限(Kbps),现在改成直播的实际档位(蓝光 8M / 高清…):
- 读到档位后,主播那一行渲染成下拉,选完立即生效;正在录制时换档会自动重录。
- 没手动选过(bitrate 0)的主播按**自上往下第二档**定下来(如
蓝光 8M / 蓝光 4M / 高清 就选蓝光 4M,只有一档就用那一档),不会一上来就吃满
最高档;已经手动选过的永远不动。
- 档位要开播后读直播源列表才知道,读到以后(录制开始时、或页面点「档位」按钮)
缓存进 config.json 的 qualities,所以没开播也能看和改;启动时会把「有缓存但还
没定过码率」的主播按第二档补齐。
- 从来没读过档位的行仍然是手填输入框,旁边多了个「档位」按钮去直播间接一次。
后端:新增 `GET /api/streamer/qualities?uid=…`(同一主播 10 秒内只读一次),
`/api/status` 的每个主播多了 `qualities`(按码率从高到低)和 `qualityName`;
抽出 `newStreamInfo()`/`fetchStreamQualities()`,`getStreamURL()` 取流时顺手缓存
档位,并在 bitrate 为 0 时改用刚定下来的默认档位(否则还是最高档)。
档位相关的纯逻辑放在 config.go:`sortQualitiesDesc` / `defaultQualityBitrate` /
`qualityNameFor` / `sameQualities` / `setStreamerQualities` / `applyCachedDefaultQualities`。
测试:qualities_test.go 7 个用例(排序、默认第二档的 5 种档位数、档位名匹配、
写配置时不覆盖手动选择、启动补齐、JSON 存取往返);浏览器实测 5 组断言全过
(下拉选中当前档位、换档服务端立即变、无档位时保留输入框+按钮、启动默认第二档、
手填仍可用),verify_streamer_br.py / verify_pick.py / verify_settings.py 无回归;
go test ./...、gofmt、go vet 干净。
|
2026-09-25 19:42:47 +08:00 |
|
tamakyi
|
a891094f29
|
合并默认改用 HEVC、码率按「源片 + 500」自动算;手动合并自动匹配同名弹幕
两件事:
1) 合成码率与编码器
- `mergeBitrate` 填 0(默认)时不再是 ICQ 质量模式,改成「源片视频码率 + 500 Kbps」:
烧进去的文字和头像都是高频细节,和源片同码率会糊,所以留一点余量;探测不到源片
码率时才退回质量模式(QSV 用 ICQ 22、软编用 CRF 18),下限 800 Kbps。
- 编码器默认换成硬件 HEVC:候选顺序 hevc_qsv → h264_qsv → libx264,QSV 可用性按
编码器分别探测(原来只探 h264_qsv)。HEVC 在 mp4 里打 `hvc1` tag,Apple 系
(QuickTime / Safari / iPhone)才认 —— ffmpeg 默认写的是 hev1。
- 日志现在会打印编码器优先顺序和实际用的码率。
实测(90 秒真实素材,源片 3217 kbps,密集弹幕):日志「按源片 3217 kbps + 弹幕
余量 500 kbps 取 3717 kbps」,成片 codec=hevc、tag=hvc1、实际 3.71 Mbps。
之前同样是这份素材:h264 + ICQ22 会飙到 7386 kbps(79 MB),改成自动码率后
43.7 MB,体积小了 45% 画质还更稳。
2) 手动合并自动匹配弹幕
- 新增 `GET /api/merge/danmu?video=…`:按视频名找同目录下的同名弹幕,依次试
`.jsonl`、`.ass`、`.nick.ass`;同目录没有就拿文件名去录播目录再找一次。
- 页面在「手填/粘贴视频后失焦」「多选弹窗选完」「从文件列表多选后」「点预览或
开始合并时弹幕框还空着」这几种情况下都会自动匹配并提示;弹幕框里已经有内容时
绝不动它,找不到也保持为空让用户自己选。
- 填回框里的写法与浏览弹窗一致:录播/成片目录下的只写文件名,别处的写绝对路径。
测试:新增 encode_test.go(编码器顺序、参数与 hvc1、自动码率、ffmpeg 输出解析)
和 TestFindDanmuForVideo(7 个子用例:优先级、录播目录回退、绝对路径、找不到返回空);
仓库外的 verify_danmu_match.py 4 组断言全过,verify_pick.py / verify_settings.py 重跑
无回归;go test ./...、gofmt、go vet 干净。
|
2026-09-25 19:23:44 +08:00 |
|
tamakyi
|
e8b455dfe8
|
合并时把弹幕坐标系统一到视频分辨率;修「改设置会删掉正在合并的临时文件」
用户手动合并一场竖屏录播(视频 720x1280,弹幕文件 960x540、字号 30)时发现:
字号改大以后文字变大了,但头像还是小小的,而且越往下的弹幕头像和文字错得越远。
原因:弹幕文件里的 PlayResX/PlayResY 是录制当时的画面尺寸,和现在的视频对不上时,
libass 会按「视频高 ÷ PlayResY」缩放文字和它的坐标(这里 1280/540 ≈ 2.37),
而头像叠加用的是视频像素坐标(头像直径 = 字号×4/5、行位置 = 字号×弹道号),
两者不在同一个坐标系里。实测同一帧(720x1280 竖屏):
修复前:文字墨迹高 54px、头像直径 24px,第 4 行文字在 y=226 而它的头像在 y=92
修复后:文字墨迹高 54px、头像直径 56px,头像与对应文字行一一对齐
做法是合并前探测视频分辨率,不一致就把 PlayRes 改成视频宽高:跟随弹幕文件时字号
按高度比换算(占画面的比例不变),用户填了 danmuFontSize 就按成片像素直接用 ——
因为用户看到的本来就是成片上的像素。只在有视频时做,透明头像层模式不变;
探测不到分辨率就保持原样。坐标系一致时(1920x1080 配 1920x1080)完全不改动,
所以正常录播的合并结果和以前一样。验证方式是拿一份「原生就对」的弹幕文件做对照,
自动统一后的成片与它在文字行位置、头像直径和位置上逐项相同。
顺带修一个真 bug(上一轮引入):checkConfig() 里清了合成临时文件,而改设置会重新
读取配置并调用 checkConfig(),于是合并途中改任何设置都会把正在用的分块字幕/临时
成片删掉,合并当场失败(日志:清理上次留下的临时文件 acfunlive_chunk_single.ass
→ Unable to open ...)。清理挪到只在启动时执行的 cleanLeftoverTempFiles()。
新增 config_test.go(改设置不能删掉在用的临时文件,反向验证过:把清理塞回
checkConfig 会让它失败)和 unifyDanmuSpace / 分辨率解析的用例。
|
2026-09-25 19:03:21 +08:00 |
|
tamakyi
|
e44dfa3a4a
|
补测试:重排对「本程序自己录的弹幕」零影响;多分段跨段冲突被一并解决
核实「弹道重排会不会影响正常录播的合并」时补的两个用例:
- recordjsonl_test.go:用真正的录制写入器 danmuWriter 造 500 条(密集,平均 0.22
秒一条),再按合并流程读回 jsonl 重排 —— 弹道变化 0 条、宽度变化 0 条。录制侧
写文件完全不经过 relayoutLanes,只有合并渲染前会重排,所以磁盘上的
.ass/.nick.ass/.jsonl 一个字节都不变,单段自动合并的成片和以前完全一致。
(顺带记下录制时确实会丢弹幕:500 条里 1 条因为 100 条弹道全满被丢弃,
jsonl 里根本没有它,重排也不会把它变出来。)
- layout_test.go:多分段(断流重连)拼接时 remapJsonlTimes 只改时间、保留各段
自己算的弹道,两段都从 0 号弹道起排,时间压缩到同一根轴后必然互相压。新增用例
先断言这种拼法本来就撞,再断言重排后冲突为 0 —— 这是重排唯一会改变输出的场景,
方向是变好,不是回归。
|
2026-09-25 18:28:52 +08:00 |
|
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 |
|
tamakyi
|
470b7668ac
|
忽略 Linux 二进制 acfunlive-danmu-linux
Linux 二进制没有扩展名,落不到 .gitignore 里任何一条现有规则里:
两个 .exe 由 `*.exe` 兜着,`acfunlive` 那行是精确匹配文件名,
管不到 `acfunlive-danmu-linux`。补一条 `/acfunlive-danmu-linux`,
以后 `git add .`、`git add -u` 都不会再误带它。
仓库里没有同前缀的源码文件,这条规则不会误伤。
|
2026-09-25 14:34:28 +08:00 |
|
tamakyi
|
a2f27974b2
|
web UI 按用途分成六个分区,整体改版并跟随系统深浅色
需求是「界面比较杂乱,做一下分类,UI 也现代化一些」。原来所有设置挤在一张
「设置」卡片里,从保存目录一路排到管理口令,加上通知推送的两个渠道,一屏
拉不到底,找东西全靠翻。
1) 分成六个分区,顶部一条导航切换
- 监控:添加主播 + 主播列表 + 运行日志(默认打开的就是这里)
- 录制:保存目录、录播/弹幕和成片子文件夹、录播格式、默认码率上限、ffmpeg 路径
- 合并:自动合并的三个开关、成片输出目录和合成码率、手动合并
- 通知:推送哪些事件、QQ 机器人渠道、MeoW 渠道
- 文件:磁盘容量 + 文件列表
- 设置:外网访问、管理口令
- 分区只靠 class 切显示,所有元素始终在 DOM 里,所以原来按 id 取值的
那一大堆逻辑一行都不用改;「监控」是默认页,因为看录制状态是最高频的操作
2) 地址栏带上 #分区名
- 用 replaceState 写,不往历史里塞记录,但刷新、收藏、把链接发给别人
都能落到同一个分区;也顺便记住了上次看的是哪一页(localStorage 兜底)
3) 大改样式(都在同一个模板里,没有引入任何外部依赖)
- 颜色、圆角、阴影收进 CSS 变量,卡片改成柔和阴影 + 大圆角,输入框加了
hover 和 focus 光晕,按钮分主次两级
- 勾选框改成开关样式(隐藏原生 input + 兄弟节点画轨道,所以 el.checked
和 :focus 判断照旧能用);主播列表的「弹幕/录播」改成小圆片
- 把原来那个行内的小 #msg 文案换成右下角提示条:成功 2.6 秒、失败 6 秒
自动消失,最多同时留 4 条,报错信息不会再一闪而过
- 顶栏常驻状态摘要(录制中 N / 直播中 N / 空闲),「保存设置」也挪到顶栏,
一直可见——文本框可能在别的分区里,按钮跟着分区走会找不到
- 跟随系统深浅色(prefers-color-scheme),配套改了 color-scheme 元信息;
日志面板在两种模式下都是深色终端风格
- 手机上标题居中、右侧按钮收成两个字(「保存」),分区导航横向滚动;
顶栏用 1fr auto 1fr 的网格,两侧等宽才是真的居中
- 文件列表里的文件名原来被 max-width:200px 截成「2026-09-24 20-00-00 Xsp…」,
文件名本来就是用来认文件的,改成不截断、允许换行
4) 顺手修的两个真问题
- 口令里有中文时,页面彻底用不了:HTTP 请求头只接受 ISO-8859-1,浏览器
遇到非 ASCII 的 X-Auth-Token 会直接抛 TypeError
(Failed to read the 'headers' property…),请求根本发不出去,页面只显示
「无法连接到服务器」,用户既进不去也没法在页面上把口令改回来。
现在页面统一把口令 encodeURIComponent 之后再放进头里,服务端
authTokenCandidates() 把原样和解码后的都作为候选逐个定长比较
(不提前返回,避免响应时间泄露是哪一种对上了),所以中文口令能用,
curl / 脚本按文档原样发中文口令也照样能过
- 删「正在写入」的文件返回 500 而不是 409,见上一个提交
5) 文档
- README 的「使用方法」补分区表、保存规则(开关立即生效 / 文本框点保存)、
顶栏状态和深浅色说明;各处「设置区」改成对应的分区名
- doc/webapi.md 补口令编码那段,说明脚本侧按原样填即可
已验证:新增 webui_test.go 5 个用例(ASCII 口令、含 % 的口令、中文口令的
编码与原样两种写法、Bearer 与 ?token= 三个来源、以及错口令/空口令/口令未配
一律拒绝、候选顺序与非 Bearer 的 Authorization 不算数),全量 39 个用例通过。
真机用 Edge 无头浏览器跑了两轮共 40 项断言,覆盖:自动登录、六个分区逐一点开
(只显示该分区、页签高亮、hash 跟着变)、六个分区 + 深色 + 三个移动端截图、
开关点了立刻落盘并能点回来、顶栏保存写到配置文件、在别的分区保存不会把隐藏
表单的值清空、刷新后仍在原分区、MeoW 红字提示的显示与隐藏、合并中按钮禁用
并改文案、口令弹窗的失败与重试,桌面/深色/移动端三处控制台零报错。
中文口令另做了端到端验证:配置里放中文口令能直接进、换口令后接口继续可用、
重开页面用新口令能进、旧口令被拒;把客户端编码去掉后同一个测试立刻失败,
确认这个修复是必需的而不是想当然。Windows/Linux 双平台构建、go vet、
gofmt 全干净。51880 的上游原版全程未动。
|
2026-09-25 14:19:44 +08:00 |
|
tamakyi
|
be8ec5f953
|
通知推送拆成两个渠道:QQ 机器人和 MeoW,各自带开关和测试连通
用户要求两件事:① QQ bot 推送要能测连通、并加一个开关;② 照 MeoW 的接口
文档(https://www.chuckfang.com/MeoW/api_doc.html)加一个推送渠道,同样要有
测试和开关。确认过的三个决定:QQ bot 就是现有 webhook;事件勾选两个渠道
共用、开关按渠道分开;MeoW 只填一个昵称。
1) 通知推送改成「共享事件勾选 + 每渠道一个开关」
- notify.go: notifySetting 扩成两个渠道,新增 qqReady()/meowReady();
sendNotify 按渠道各自起后台 goroutine 分发,事件没勾选、或两个渠道都
没启用时什么都不做
- config.go: 新增 notifyEnabled(QQ 机器人渠道开关)、meowEnabled、
meowNickname、meowMsgType、meowHtmlHeight、meowImgUrl
- 关掉开关只是暂停推送,地址、昵称这些配置都留着,随时能再打开
2) 老配置的兼容(重要)
- notifyEnabled 是普通 bool,老 config.json 里没有这个字段会解成 false,
升级后会把已经配好的推送静默关掉
- 解法:loadConfig() 里先置 true 再 json.Unmarshal(Unmarshal 只覆盖文件里
出现的字段),默认值里也写 true;文件里显式写 false 的仍然会关掉
- 已加回归测试 TestNotifyEnabledBackwardCompat 覆盖三种写法
3) MeoW 渠道(新增 meow.go)
- 按文档走 POST {昵称},title/msg/url/imgUrl 放 json body,
msgType/htmlHeight 是 query 参数(文档明确说这两个不属于 JSON 参数)
- 文档自己前后矛盾:响应格式那节写 msg,示例写 message,两个字段都解析
- 空字段一律不放进 body:body 里的 title 优先级高于路径参数,
传空串会把 MeoW 的默认标题顶掉
- 业务状态码翻成中文(400 参数错、403 内容被拦、404 昵称未注册、
429 频控、500 服务器错误),失败信息直接显示在页面上
- msgType 为 html 时渲染成 HTML(首行加粗、链接可点、内容转义),
高度默认 200、范围 40-2000;事件正文复用 webhook 的那份 text
4) MeoW 的错误昵称会封 IP,所以加了保护(踩出来的)
- 实测用不存在的昵称打真实接口,返回的提示是「一天之内累计3次输错对方
昵称您的IP将被封禁」。而开播+开始录制+下播+合并完成一天轻松触发 4 次,
昵称配错就会把用户 IP 封了
- meow.go: 新增 meowBadNick 记录,pushMeow 收到 404 就记下这个昵称;
sendNotify 和测试接口命中记录时直接跳过/拒绝并说明原因,
改掉昵称或重启程序即恢复
- 只有 404 触发拦截:429 是频控、400 是参数问题,都应该继续尝试
- 已加测试 TestMeowBadNickGuard(404 要拦、429/400 不能拦、不影响别的昵称)
5) 测试连通
- /api/notify/test 合并成一个接口,用 channel 区分 qq / meow,
每个渠道的配置下面各有一个「测试连通」按钮
- 用页面上当前填的值直接测、不用先保存,留空的项自动用已保存的值补齐;
测试不受渠道开关和事件勾选影响(本来就是为了打开之前先确认配置)
- 成功返回 {ok, detail},detail 就是页面上的结论:QQ 渠道显示 webhook 的
HTTP 状态码和耗时,MeoW 渠道显示它返回的 status 和耗时;
webhook 返回 200 只代表地址口令没问题,能不能收到要看机器人那边,
这点也写在结果里
- /api/status 新增 meowBlocked,页面上红字显示「当前推不出去:…」
(昵称没填、格式不对、或已被判过未注册)
6) 页面
- 通知区分成「事件勾选 / 渠道一 QQ 机器人 / 渠道二 MeoW」三块,
每块用一条带标题的细线隔开,两个渠道各带开关和测试按钮
- 消息类型选 text 时自动收起 HTML 高度那一行
另外:README 的通知推送一节重写(两个渠道的对比表、MeoW 的频控与封禁提醒、
测试连通的说明),配置文件详解补上新字段;doc/webapi.md 补 /api/notify/test
和 meowBlocked。doc/momoko-acfunlive-notify.py 本来就处理 event="test",不用改。
已验证:新增 notify_test.go,15 个用例全部通过(URL 拼接与转义、昵称校验、
消息类型与高度归一、HTML 渲染与转义、接口成功/失败/空字段处理、6 种业务状态码
的文案、404 拦截保护、老配置兼容、测试接口各分支),其中 TestSendNotifyFanOut
是端到端分发测试:两个渠道都开→各收到 1 条、关掉任一个→只剩另一个、
两个都关→0 条、事件没勾→0 条。真机冒烟两轮:页面控件齐全、老配置默认开启、
QQ 渠道实测本地 webhook 收到完整推送(含 Authorization 和 X-Auth-Token 头与群号)、
空值回退到已保存配置、6 种非法输入全 400、开关能落盘且关开关不清地址、
MeoW 真实接口返回的 404 被正确翻成中文。全量 34 个测试通过,
Windows/Linux 双平台构建、go vet、gofmt 全干净。
|
2026-09-25 13:42:49 +08:00 |
|
tamakyi
|
ed4e47e305
|
init
|
2026-09-25 13:16:54 +08:00 |
|