From 072604eed554e7ee6d5554b850810eb250f84b47 Mon Sep 17 00:00:00 2001 From: Noah Date: Tue, 22 Sep 2026 16:25:00 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=9B=B4=E6=92=AD=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=E4=BA=8C=E2=80=94=E2=80=94=E5=86=85=E7=BD=AE?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E5=99=A8=E5=A4=8D=E7=94=A8=E5=BD=95=E5=88=B6?= =?UTF-8?q?=E6=B5=81=EF=BC=88=E5=8D=A1=E7=89=87=E5=BC=B9=E5=B1=82=20+=20?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E9=A1=B5=E7=9B=91=E8=A7=86=E5=99=A8=EF=BC=89?= =?UTF-8?q?=20(#1712)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(downloader): 直播预览 hub 与写盘旁路(httpflv / hls / mesio) 每路录制任务一个 PreviewHub(寿命 = 整场 execute(),跨分段与断流重试),下载器开始拉流时 attach 一个写入端,在写盘点旁边把同一份媒体字节 push 给它:httpflv 在关键帧写出循环、 stream-gears/hls 在分片 chunk 循环、mesio 在条目进入修复管线前。fMP4 分片流与 HEVC 序列头 标为不可预览;ffmpeg / streamlink / yt-dlp / 边录边传明确不可预览并给出原因。 写入端热路径只做三件事:GOP 缓冲追加、try_recv 订阅请求、broadcast::send。没有 .await、 没有锁等待、没有 ?;订阅请求由写入端在下一次推送时回应,快照(文件头 + 序列头 + 当前 GOP) 与随后的实时分块严格衔接。GOP 快照上限 8 MB,广播缓冲 256 槽 × 64 KB 且无订阅者时不占用, 每路订阅者数由信号量限制;慢订阅者收到 Lagged 后由端点断开让其重连。 Part of #1708 * feat(api): GET /v1/streamers/{id}/live 与列表的 preview 字段 新增 chunked 视频流端点:先发关键帧对齐的快照再接实时分块,Content-Type 按容器为 video/x-flv 或 video/mp2t,Cache-Control: no-store。房间不存在 / 未录制 404,下载器不能 旁路 415(带原因),连接数超限 429(每路 4、进程 16),尚未开始拉流或正在重连 503 + Retry-After。慢客户端 Lagged 或写入端换代时结束响应,由播放器重连。注册在 router() 内, --auth 时与 /v1/streamers 同一道登录校验。 GET /v1/streamers 每项新增 preview: { available, format: "flv" | "mpegts" | null, reason }, 未录制为 null。 Part of #1708 * feat(preview): fMP4 分片流也能预览——init segment 快照、按分片广播、解出 codecs mesio 的 HLS 会话拿到 fMP4 分片时不再标为不可预览:init segment(ftyp + moov)作为文件头 进快照,每个 moof + mdat 分片作为一个关键帧分块广播,新订阅者拿到 init + 最近一个完整分片, 端点 Content-Type 为 video/mp4,列表字段 preview.format 为 "fmp4"。 从 init segment 的 moov/trak/…/stsd 解出 RFC 6381 编码串(avc1.PPCCLL、hvc1.…、mp4a.40.N、 av01 / vp09 / ac-3 / ec-3 / opus / flac)放进 preview.codecs,供前端 MediaSource.isTypeSupported() 校验后 addSourceBuffer;解析只在 attach 后收到 init 时做一次,不在逐分块路径上。 stream-gears 的 HLS 路径没有下载 #EXT-X-MAP 初始化分片,遇到非 TS 分片时标为不可预览并说明原因。 按 ForgQi 在 #1708 的要求把 fMP4 纳入阶段二。 Part of #1708 * feat(ui): 直播间卡片的「预览」弹层——复用录制流的内置播放器 Player.tsx 加 isLive / muted / autoplay / type / codecs 与 onEnded / onError:flv 与 mpegts 走 mpegts.js(直播模式:不攒缓冲、追帧、自动清理 SourceBuffer),fmp4 走一个轻量 MediaSource 播放器(fetch ReadableStream → appendBuffer 串行队列,首个缓冲对齐播放位置,落后 > 3 s 追到 末尾,定期 remove 30 s 之前的缓冲),都以 Artplayer customType 挂载保持 UI 一致; type 未知时用一次 GET 读 Content-Type 再起播。MediaSource.isTypeSupported 不通过时报错并显示 封面 + 原因,不转圈。 StreamerCard 录制中加「预览」图标按钮(不可预览时禁用,tooltip 显示后端给的原因),封面可点 即打开预览;弹层(LivePreview.tsx)关闭即卸载播放器、断开连接。服务端断开(掉队 / 换直链) 自动重连 3 次,之后交给用户手动重试;不可恢复错误 Toast 提示一次。零新依赖。 Part of #1708 * feat(ui): 历史记录页「实时监视」Tab——多路同屏监视器 是独立组件:所有正在录制的直播间以小窗网格展示,可预览且在同屏路数内的 直接播放(默认静音),超出的显示封面、点击替换最早开始的一路(先进先出),下载器不能旁路 的显示封面与原因。同屏路数默认 DEFAULT_MONITOR_TILES = 4(可选 1/2/4/6/9,localStorage 记住), 每路小窗占对应直播间的一个预览连接。正在播放的列表由「用户选择 + 当前可预览房间 + 路数」 纯推导,不在 effect 里 setState。 历史记录页改为两个 Tab:「录制文件」(原表格)与「实时监视」,keepDOM=false 切走即卸载全部 播放器。要把监视器挪到别的页面只需换挂载点。 Part of #1708 * test(preview): 让旁路测试不依赖调度时机 rolling 测试先把订阅请求 poll 进写入端队列再开始写盘,源数据按 2 ms 一块限速送入而不是瞬间灌完 (瞬间灌完时订阅者必然掉队,那是慢订阅者测试覆盖的场景);慢订阅者测试只断言 「收到 + 被告知跳过 = 全部」与写入端不等待,不再对读取任务在负载下的吞吐做假设。 * fix(ui): mpegts.js 的 MSE 解码失败给出可读提示 典型来源是 TS 的 PES 没按访问单元对齐(如 Twitch 的 HLS),mpegts.js 拆出的帧不完整、浏览器拒绝 解码;原文 "Failed to execute 'appendBuffer'…" 对用户没有信息量,改为说明原因并强调录制不受影响。 * fix(preview): fMP4 与 TS 的快照从关键帧起,而不是从分段边界起 B 站 hls_fmp4 的分段(约 1 s)各含多个 moof/mdat 分片、且分段边界不对齐关键帧(实测 10 次冷启动 只有 1 次首帧是 I 帧):写入端现在把每个分段按 moof/mdat 切开,读 init 里的视频轨 ID 与 trex 默认 flags、trun 的 first_sample_flags / 首样本 flags / tfhd 默认 flags,视频首帧为同步样本的 分片才作为 GOP 起点。TS 分片起点则嗅探第一个视频 PES 的 NAL 类型(IDR/SPS → 起点,只有普通片 → 追加到上一 GOP,说不准 → 按起点处理)。FLV 路径本来就按关键帧 tag 切,未变。 单测覆盖:分片切分(前导 styp、非同步 / 同步 / trex 默认 / 无视频轨)、跨分段边界的快照、 TS 嗅探(跨包的 PES、音频 PES 在前、非 TS 输入);另有 #[ignore] 的 B 站真实抓样核对。 * feat(danmaku): 预览播放器的实时弹幕——录制中的弹幕客户端旁路一份到 SSE danmaku crate 的 RecorderConfig 新增可选的 live_tx(tokio broadcast):每解出一条事件,在写 XML 之前 send 一份(不等待,无订阅者即丢弃),XML 录制路径不变。DownloadTask 按 stream.danmaku 是否存在建一条 256 槽的广播,传给 danmaku_client;新增 GET /v1/streamers/{id}/danmaku(SSE, 每条 event 为 danmaku / gift / super_chat / guard_buy,data 为 {kind, text, name, color, ts}, 进场消息不进;掉队跳过不断开;进程上限 32 路,超出 429;无弹幕客户端的平台 415)。 /v1/streamers 的 preview 字段新增 danmaku: bool,前端据此启用 / 禁用弹幕开关。 Part of #1708 * feat(ui): 预览弹层装载实时弹幕(开关控制),监视器默认关 Player.tsx 新增 danmaku: { url, enabled }:装 artplayer-plugin-danmuku(新依赖,Artplayer 官方弹幕 插件,5.3.0),开关打开时用 EventSource 连 /v1/streamers/{id}/danmaku,每条事件 emit 进弹幕层 (普通弹幕滚动、礼物 / 醒目留言 / 上舰置顶描边),关闭时断开 SSE 并隐藏弹幕层,不重建播放器。 弹层标题栏加「弹幕」Switch,默认开、记在 localStorage(biliup.preview.danmaku);平台没有弹幕 客户端时禁用并 tooltip 说明。监视器工具栏加同名开关,默认关(biliup.monitor.danmaku), 对全部小窗生效。localStorage 偏好统一走 useSyncExternalStore(app/lib/use-local-pref.ts), 同屏路数也改用它。 Part of #1708 * fix(preview): FLV 广播缓冲扩到 1024 槽,装得下 CDN 的整 GOP 突发 斗鱼 CDN 把整个 GOP(~4 s、~320 个 tag、~2 MB)一次突发送达,httpflv 解析器 1 ms 内推完; 刚建立的订阅者还没读到第一个实时分块,256 槽的缓冲就已被覆盖,收到 Lagged 被断开,重连后 又赶上下一个突发——冷启动 10 次只有 1 次播起来。FLV 分块是单个 tag,小而多,缓冲按格式分开: FLV 1024 槽(实测均值 ~10 KB → ~10 MB),TS / fMP4 沿用 256 槽(分块大而少,16 MB 上限)。 回归测试:请求在关键帧回应后紧跟 600 个 tag 同步推完,订阅者仍逐个读到。 Part of #1708 * fix(ui): 弹幕开关禁用时说明是平台不支持还是未开对应 *_danmaku 配置 * feat(danmaku): 多个直播间的实时弹幕可复用一条 SSE(GET /v1/danmaku?ids=…) 浏览器对同一主机的 HTTP/1.1 并发连接只有 6 个:监视器同屏 N 路视频已占 N 个,弹幕若每路再开 一条 SSE,N=4 时就把列表轮询也饿死。新增 /v1/danmaku?ids=1,3,9 把若干路合成一条,事件 JSON 带 id 供前端分发;没有弹幕 / 未录制的 id 静默跳过,一个都没有时 404;一条最多 16 路,占 1 个 连接许可。原 /v1/streamers/{id}/danmaku 保留,走同一实现(事件同样带 id)。 Part of #1708 * feat(ui): 弹幕连接由页面持有并复用,监视器路数按 HTTP 协议限制 浏览器对同一主机 HTTP/1.1 只有 6 个并发连接,监视器 4 路视频 + 每路一条弹幕 SSE 就把列表轮询 饿死;9 路时也只有 6 路能播(实测)。改为 DanmakuFeed:页面(弹层 / 监视器)持有一条 /v1/danmaku?ids=… 连接,各播放器按 id 订阅;构造无副作用、open() 后有订阅者才连,StrictMode 下重复构造无害。同屏路数选项在 HTTP/1.1 下为 1 / 2 / 4,页面经 h2 / h3 加载时放开 6 / 9。 小窗弹幕字号 15px,弹层 22px。 Part of #1708 * fix(ui): 解码失败后关闭弹层必须断开拉流连接