Files
biliup/app/ui/LiveMonitor.tsx
Noah 48dc8fd65b fix(preview): 中转模式在有 RTT 的链路上频繁缓冲——旁路挪到解析点、掉队原地续播、缓冲分低延迟 2 s / 流畅 5 s 两档 (#1716)
* fix(preview): stream-gears FLV 在解析点旁路,不等写盘循环整 GOP 落盘

httpflv 的写盘循环把 tag 攒在 flv_tags_cache 里,等下一个关键帧到来才把整个 GOP
一起写出——预览的旁路点原来就挂在这个写出循环里,于是预览端每个 GOP 间隔(B 站
常见 2–4 s)收到一次整 GOP 的突发、其余时间一个字节都不来,且整体落后一个 GOP。
播放器拿到的缓冲恰好在下一个突发到达时耗尽,零余量:同机 0 ms 下勉强连上,链路
上稍有 RTT / 抖动就 waiting;GOP 长于 mpegts.js 的追帧阈值时更是每个 GOP 都跳一次
再停一次(实测 GOP 4.2 s 的房间同机也有 85% 时间处于 waiting)。

把旁路挪到 tag 解析完、进缓存之前:预览按 tag 实时收到,与 mesio 路径(在条目进
管线前 tee)一致。写盘循环一个字节不变;旁路仍只是 push,不 await、不返回错误。

实测(arrival-probe,40 s):同一斗鱼房间直连 CDN 到达连续(最大静默 0.24 s),
中转修前每 2.0 s 一个 ~940 KB 突发、20 个静默间隙全部 > 1 s。

Refs #1712 #1713

* feat(preview): 快照保留最近 6 s 的 GOP,新订阅者起播就有几秒缓冲

原来快照只含「当前 GOP」:新订阅者从零到一个 GOP 之间的缓冲起步,之后到达 = 消耗,
缓冲永远这么薄,链路上几十到几百毫秒的抖动就 waiting。浏览器直连 CDN 时 CDN 会先
给几秒的 GOP 缓存,中转得给同样的深度。

PreviewSink 里多一个已完成 GOP 的队列(按关键帧到达时刻计,保留 SNAPSHOT_WINDOW = 6 s
以内的),快照 = 文件头 + 序列头 + 队列里的 GOP + 当前 GOP,实际深度在 6 s 到 6 s +
一个 GOP 之间。字节上限 MAX_SNAPSHOT_BYTES = 16 MB(6 s × 20 Mbps),超过先丢最旧的
GOP;单个 GOP 超过 MAX_GOP_BYTES 作废时历史一并放掉(不再连续);序列头内容变了或
fMP4 换了 init 时历史也放掉(旧 GOP 与新参数不配),内容相同的重发(stream-gears
分段时重放 onMetaData / 序列头)不影响。热路径只多几次 usize 比较与一次 VecDeque
push,仍不 await、不返回错误;无订阅者时同样保留(第一个订阅者也要立刻有缓冲),
每路多占约 6 s 的码率(1080p 2–5 MB)。

`with_snapshot_window(Duration::ZERO)` 给只关心分块判定的测试用。

Refs #1712 #1713

* fix(preview): 客户端掉队时从最近关键帧原地续播,不再断开重连

广播缓冲被覆盖(Lagged)时原来直接结束响应,浏览器看到「连接已断开,2 秒后重连」
的加载态,再花一次 HTTP 往返 + 等下一个关键帧才恢复;带宽长期偏紧的链路上就是
反复「触发加载」。

现在 hub 多一个 `resubscribe(previous, timeout)`:复用原订阅的连接许可(不占第二
个名额),向写入端再要一份从最近关键帧起的快照与新接收端;响应在同一条连接里续
发这份快照——FLV 去掉文件头(`Subscription::snapshot_after_header`,解码器不能中途
再收到文件头;序列头照给,掉队期间可能换过),fMP4 的 init segment 照发(MSE 接受
中途再来一份)。mpegts.js 的 remuxer 会把批次之间的时间戳跳变收平,画面表现为跳
过一段而不是转圈;一次响应最多 MAX_RESYNCS_PER_RESPONSE = 20 次,再多说明客户端
长期跟不上,结束响应交给播放器。每次重对齐 warn 日志带 skipped 与累计次数。

Refs #1712 #1713

* perf(preview): 客户端追赶积压时把广播缓冲里攒着的分块合并写出

实时转发每收到一个分块就写一个 chunked 帧(FLV 一个 tag 几百字节到几十 KB)。
客户端跟得上时缓冲里本来就只有一个,无从合并;追赶积压(起播快照刚发完、链路
抖动之后)时缓冲里攒着几十上百个,逐个写出是几十上百次 socket 写与 chunked 帧。
`coalesce` 在 `recv()` 到手后用 `try_recv` 把已经在缓冲里的后续分块合并成一个
响应块(上限 COALESCE_BYTES = 64 KB),缓冲空着就原样透传不复制,不引入任何等待;
合并途中撞上 Lagged 时把跳过数交回主循环按掉队处理,不吞掉。

Refs #1712 #1713

* fix(ui): 中转模式的播放器维持约 5 s 缓冲,用倍速而不是跳变追帧

mpegts.js 原来对中转、直连一律 liveBufferLatencyMaxLatency 3 s / MinRemain 0.5 s:
缓冲一超过 3 s 就跳到末尾前 0.5 s,等于把服务端刚给的几秒快照直接扔掉,之后只剩
0.5 s 余量,链路一抖就 waiting;GOP 长于 3 s 的流更是每个 GOP 跳一次再停一次。

按取流方式分两套参数(`Players` 新 prop `transport`,LivePreviewPlayer 按实际 source
传):
- relay:liveSync 倍速追帧——落后 > 9 s 用 1.1× 追到 5 s 附近,≤ 5 s 恢复 1×;
  只在落后 > 20 s(长时间暂停后)才直接跳到末尾前 5 s。与服务端 6 s 的快照窗口配合,
  播放器起播就有约 5 s 缓冲并一直保持。
- direct:沿用原参数(CDN 自带 GOP 缓存,ForgQi 实测异地直连不卡,不动)。

fMP4 的 MediaSource 播放器(只在中转路径上)同样改成:起播对齐到末尾前 5 s、
落后 > 9 s 用 1.1× 追、> 20 s 才跳;另加空洞跳过——服务端掉队重对齐后分片时间戳
前跳,缓冲区出现空洞,停在空洞前沿时跳到下一段起点,不再原地转圈。

Refs #1712 #1713

* style(preview): cargo fmt 与 clippy(len_zero)

* feat(preview): /live 支持 snapshot_ms,起播快照按播放器要的缓冲深度回溯

新订阅者拿到多深的快照,播放器就有多深的起播缓冲,也就是多长的中转延迟。之前一律给整个 6 s
保留窗口,只适合「流畅」档;低延迟档要 2 s 就只该拿 2 s,而不是拿 6–10 s 再用 1.1 倍速追 40 s。

- PreviewHub::subscribe_with_depth(depth):从当前 GOP 往前取,取到第一个关键帧到达时刻早于
  「现在 − depth」的 GOP 为止(含),起播缓冲落在 depth 到 depth + 一个 GOP 之间;Some(ZERO)
  只给当前 GOP,None 给整个窗口。Subscription 记住 depth,Lagged 重新对齐沿用。
- GET /v1/streamers/{id}/live?snapshot_ms=2000;不带参数行为不变。「开始直播预览」日志多一个
  snapshot_ms 字段。
- 保留窗口 SNAPSHOT_WINDOW 仍是 6 s,服务端内存不变;热路径只在有订阅请求时多一次
  VecDeque::make_contiguous(历史只有几个 GOP)。
- 单测 +2:深度切分的边界(零 / 跨线 / 大于全部历史 / 空历史)、带深度订阅与重对齐沿用深度。

Refs #1713 #1716

* feat(ui): 中转预览分「低延迟 / 流畅」两档,默认低延迟 2 s,卡了自动升档

ForgQi 在 #1713 下问「中转有办法不延迟 5 s 也不卡顿吗」。主因(整 GOP 成坨下发)修掉后 5 s 是
过度配置:同一条模拟链路(60 ms RTT ± 16 ms、丢包重传停顿、20 Mbps)上四路真实房间扫
1 / 1.5 / 2 / 3 / 5 s 目标缓冲各播 180 s,三路 FLV 从 1 s 起就 0 次 waiting;只有 HLS 来的
fMP4 要盖住一个分片(B 站 1–3 s),3 s 起 0 次、4 s 余量最低约 1–2 s。

- app/lib/live-buffer.ts:缓冲控制统一到一处(原来 mpegts.js 的 liveSync 管 FLV、fMP4 播放器
  自己一套 chase):起播跳到「末尾 − target」,快照落地那几秒里超了再对齐(慢链路上几 MB 要
  几秒才到齐,每对齐一次多看 2 s、最多 15 s),稳态超 max 倍速 / 超 hard 跳 / 跳过重对齐留下的
  空洞,稳态里缓冲耗尽一次报 onStall。档位:low = FLV 2 s / fMP4·TS 4 s,smooth = 5 s
  (目标与原 0d41140 相同,倍速阈值 9 → 7)。mpegts.js 在中转模式下关掉自己的追帧,直连一行未动。
- /live 请求带 snapshot_ms = 目标缓冲,起播就是目标深度,不再从 6–10 s 追回来。
- 低延迟档在稳态里卡一次 ≥ 1 s 或 60 s 内两次,本次播放升到流畅档重连(更深的快照起播),
  角标「卡顿,已切到流畅档」;换房间或改偏好即复位。
- 弹层标题栏与监视器工具栏各一个「低延迟 / 流畅」开关,记 localStorage biliup.preview.latency,
  默认低延迟;直连模式下不显示。

实测(同一模拟链路,180 s,hub 最新时间戳 → 已渲染):低延迟档三路 FLV 延迟中位 2.1–2.4 s、
fMP4 2.8–3.7 s,四路 0 次 waiting;流畅档 5.1 s。每 18 s 停 2.5 s 的链路上低延迟档卡两次后
自动升档,之后 130 s 0 次。

Refs #1713 #1716
2026-09-23 06:20:47 +00:00

319 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import React, { useState } from 'react'
import useSWR from 'swr'
import { Button, Empty, Select, Switch, Tag, Tooltip, Typography } from '@douyinfe/semi-ui'
import { IconPlay, IconStop } from '@douyinfe/semi-icons'
import { fetcher, LiveStreamerEntity, StreamerInfo } from '@/app/lib/api-streamer'
import { LIVE_STATUS, platformName } from '@/app/lib/status'
import {
canPreview,
formatRate,
liveImageUrl,
previewDisabledReason,
previewFormatLabel,
SLOW_REFRESH_MS,
STREAMERS_REFRESH_MS,
usePreviewTransport,
} from '@/app/lib/use-dashboard'
import { useBoolPref, useChoicePref } from '@/app/lib/use-local-pref'
import { useDanmakuFeed } from '@/app/lib/danmaku-feed'
import { LivePreviewPlayer, useLatencyProfile } from './LivePreview'
import { type LatencyProfile, RELAY_PROFILES, RELAY_PROFILES_SEGMENTED } from '@/app/lib/live-buffer'
import { LiveRateChart, SPARK_RATE_WINDOW_MS } from './LiveRateChart'
import styles from './live-monitor.module.scss'
/**
* 监视器默认同屏路数。每路小窗占用对应直播间的 1 个预览连接
* (服务端每间上限见 `PREVIEW_MAX_SUBSCRIBERS_PER_ROOM`,进程上限 16),改这里即可换默认值。
*/
export const DEFAULT_MONITOR_TILES = 4
/**
* 浏览器对同一主机的 HTTP/1.1 并发连接只有 6 个:每路小窗占 1 个长连接,弹幕复用 1 个,
* 列表轮询还要留 1 个,所以 HTTP/1.1 下最多 4 路;页面走 HTTP/2、HTTP/3(TLS 反代)时不受此限,
* 放开 6 / 9 路。
*/
export const MONITOR_TILE_OPTIONS_H1 = [1, 2, 4]
export const MONITOR_TILE_OPTIONS_H2 = [1, 2, 4, 6, 9]
function tileOptions(): number[] {
if (typeof window === 'undefined') return MONITOR_TILE_OPTIONS_H1
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined
const proto = nav?.nextHopProtocol ?? ''
return proto === 'h2' || proto === 'h3' ? MONITOR_TILE_OPTIONS_H2 : MONITOR_TILE_OPTIONS_H1
}
const TILES_KEY = 'biliup.monitor.maxTiles'
/** 多路同屏默认不开弹幕(每路一条 SSE、多路叠加太吵),开关记在本地 */
const DANMAKU_KEY = 'biliup.monitor.danmaku'
/** 每路小窗下的 60 s 码率 sparkline,默认关(多路同时画 + 每秒轮询),开关记在本地 */
const RATE_CHART_KEY = 'biliup.monitor.rateChart'
/** 用户的选择:手动激活的顺序(先进先出)与手动停掉的房间;其余由数据推导。 */
interface Selection {
order: number[]
stopped: number[]
}
/**
* 由「用户选择 + 当前可预览房间 + 路数上限」推导正在播放的房间列表(纯函数,无副作用):
* 先保留用户激活且仍可预览的,再按列表顺序自动补满,手动停掉的不自动补。
*/
export function resolveActive(
selection: Selection,
previewableIds: number[],
maxTiles: number
): number[] {
const previewable = new Set(previewableIds)
const stopped = new Set(selection.stopped)
const active: number[] = []
for (const id of selection.order) {
if (previewable.has(id) && !active.includes(id)) active.push(id)
}
for (const id of previewableIds) {
if (active.length >= maxTiles) break
if (!active.includes(id) && !stopped.has(id)) active.push(id)
}
return active.slice(Math.max(0, active.length - maxTiles))
}
/**
* 实时监视:所有正在录制的直播间的小窗网格。
* 可预览且在路数上限内的直接播放(默认静音);超出的显示封面,点击替换最早开始的一路;
* 下载器不能旁路的显示封面与原因。独立组件,可整体挪到别的页面。
*/
export default function LiveMonitor() {
const { Text } = Typography
const { data: streamers, error, isLoading } = useSWR<LiveStreamerEntity[]>(
'/v1/streamers',
fetcher,
{ refreshInterval: STREAMERS_REFRESH_MS }
)
const { data: infos } = useSWR<StreamerInfo[]>('/v1/streamer-info', fetcher, {
refreshInterval: SLOW_REFRESH_MS,
})
const options = tileOptions()
const [maxTiles, setMaxTiles] = useChoicePref(TILES_KEY, options, DEFAULT_MONITOR_TILES)
const [danmaku, setDanmaku] = useBoolPref(DANMAKU_KEY, false)
const [latency, setLatency] = useLatencyProfile()
const transport = usePreviewTransport()
const [rateChart, setRateChart] = useBoolPref(RATE_CHART_KEY, false)
const [selection, setSelection] = useState<Selection>({ order: [], stopped: [] })
const live = (streamers ?? []).filter((s) => s.status === LIVE_STATUS)
const previewable = live.filter(canPreview)
const previewableIds = previewable.map((s) => s.id)
const active = resolveActive(selection, previewableIds, maxTiles)
const activeSet = new Set(active)
// 正在播且平台有弹幕客户端的那几路共用一条弹幕连接
const danmakuIds = previewable.filter((s) => activeSet.has(s.id) && s.preview?.danmaku).map((s) => s.id)
const danmakuFeed = useDanmakuFeed(danmakuIds, danmaku)
const infoByUrl = new Map<string, StreamerInfo>()
for (const i of infos ?? []) {
const cur = infoByUrl.get(i.url)
if (!cur || i.date > cur.date) infoByUrl.set(i.url, i)
}
// 激活:满了就替换最早开始的一路(先进先出)
const activate = (id: number) => {
setSelection((prev) => {
const current = resolveActive(prev, previewableIds, maxTiles)
const kept = current.length >= maxTiles ? current.slice(1) : current
return {
order: [...kept.filter((x) => x !== id), id],
stopped: prev.stopped.filter((x) => x !== id),
}
})
}
const stop = (id: number) => {
setSelection((prev) => ({
order: prev.order.filter((x) => x !== id),
stopped: prev.stopped.includes(id) ? prev.stopped : [...prev.stopped, id],
}))
}
if (error) {
return (
<div className={styles.center}>
<Empty title="加载失败" description="无法获取主播列表,请检查后端连接" />
</div>
)
}
if (!isLoading && live.length === 0) {
return (
<div className={styles.center}>
<Empty
title="当前没有正在录制的直播间"
description="开播并开始录制后,这里会以小窗形式同屏显示各路画面(stream-gears / mesio 下载器)"
/>
</div>
)
}
const full = active.length >= maxTiles
return (
<section className={styles.monitor}>
<div className={styles.toolbar}>
<div className={styles.summary}>
<Text strong>
正在监视 {active.length} / {maxTiles} 路
</Text>
<Text type="tertiary" size="small">
录制中 {live.length} 间 · 可预览 {previewable.length} 间
</Text>
</div>
<div className={styles.controls}>
<Tooltip content="所有小窗一起开关;只有平台实现了弹幕客户端的房间会显示">
<span className={styles.switchRow}>
<Text type="tertiary" size="small">
弹幕
</Text>
<Switch size="small" checked={danmaku} onChange={(v) => setDanmaku(!!v)} aria-label="弹幕" />
</span>
</Tooltip>
{transport === 'relay' ? (
<>
<Tooltip
content={`中转缓冲深度,也就是画面延迟。低延迟:FLV 约 ${RELAY_PROFILES.low.target} s、HLS 分片流约 ${RELAY_PROFILES_SEGMENTED.low.target} s,卡了的小窗自动切到流畅;流畅:约 ${RELAY_PROFILES.smooth.target} s`}
>
<Text type="tertiary" size="small">
延迟
</Text>
</Tooltip>
<Select
size="small"
value={latency}
onChange={(v) => setLatency(v as LatencyProfile)}
optionList={[
{ value: 'low', label: '低延迟' },
{ value: 'smooth', label: '流畅' },
]}
style={{ width: 96 }}
aria-label="中转延迟"
/>
</>
) : null}
<Tooltip content="每路小窗下方显示最近 60 秒的写盘速率折线(每秒向后端拉一次速率,所有小窗共用一次请求)">
<span className={styles.switchRow}>
<Text type="tertiary" size="small">
码率图
</Text>
<Switch size="small" checked={rateChart} onChange={(v) => setRateChart(!!v)} aria-label="码率图" />
</span>
</Tooltip>
<Text type="tertiary" size="small">
同屏路数
</Text>
<Select
size="small"
value={maxTiles}
onChange={(v) => setMaxTiles(Number(v))}
optionList={options.map((n) => ({ value: n, label: `${n} 路` }))}
style={{ width: 88 }}
aria-label="同屏路数"
/>
</div>
</div>
<Text type="tertiary" size="small" className={styles.hint}>
画面来自各直播间正在写盘的同一路流,默认静音;每路小窗占用该直播间的 1 个预览连接。
超出路数的直播间显示封面,点击可替换最早开始的一路。
</Text>
<div className={styles.grid}>
{live.map((s) => {
const info = infoByUrl.get(s.url)
const playing = activeSet.has(s.id)
const can = canPreview(s)
const reason = can ? null : previewDisabledReason(s)
const cover = liveImageUrl(s.id, 'cover', s.live_cover_url)
const rate = formatRate(s.live_bytes_per_sec)
const name = s.remark || s.url
return (
<article
key={s.id}
className={`${styles.tile} ${playing ? styles.tilePlaying : ''}`}
data-id={s.id}
data-state={playing ? 'playing' : can ? 'idle' : 'unavailable'}
>
<header className={styles.tileHead}>
<span className={styles.tileName} title={name}>
{name}
</span>
<Tag size="small" color="grey">
{platformName(s.url)}
</Tag>
{previewFormatLabel(s.preview?.format) ? (
<Tag size="small" color="green">
{previewFormatLabel(s.preview?.format)}
</Tag>
) : null}
<span className={styles.tileRate}>{rate ?? '—'}</span>
{playing ? (
<Tooltip content="停止这一路">
<Button
size="small"
theme="borderless"
type="tertiary"
icon={<IconStop />}
aria-label={`停止 ${name}`}
onClick={() => stop(s.id)}
/>
</Tooltip>
) : null}
</header>
{playing ? (
<LivePreviewPlayer streamer={s} muted compact danmakuFeed={danmaku ? danmakuFeed : null} />
) : (
<button
type="button"
className={styles.idle}
disabled={!can}
onClick={() => activate(s.id)}
aria-label={
can ? (full ? `播放 ${name}(替换最早的一路)` : `播放 ${name}`) : `${name}:${reason}`
}
>
{cover ? (
// 封面走同源代理;失败时隐藏,只剩底色,说明文字仍在
// eslint-disable-next-line @next/next/no-img-element
<img
className={styles.idleCover}
src={cover}
alt=""
aria-hidden="true"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
) : null}
<span className={styles.idleText}>
{can ? (
<>
<IconPlay />
{full ? '点击替换最早的一路' : '点击开始预览'}
</>
) : (
reason
)}
</span>
</button>
)}
{rateChart ? (
<LiveRateChart
id={s.id}
windowMs={SPARK_RATE_WINDOW_MS}
variant="sparkline"
height={32}
label={`${name} 写盘速率`}
className={styles.tileSpark}
/>
) : null}
<footer className={styles.tileFoot} title={info?.title || ''}>
{info?.title || '\u00a0'}
</footer>
</article>
)
})}
</div>
</section>
)
}