* 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
170 lines
6.9 KiB
TypeScript
170 lines
6.9 KiB
TypeScript
/**
|
||
* 中转直播流的缓冲控制:播放器该维持多深的缓冲、落后了怎么追。
|
||
*
|
||
* 中转流的「缓冲深度」就是用户看到的延迟(服务端收到 → 画面播出),两者只差一个链路传输时间:
|
||
* 服务端按实时节奏转发,播放器攒着不放的那几秒既是抵消抖动的余量,也是延迟。
|
||
* 所以起播时向服务端要多深的快照(`snapshot_ms`)、稳态维持多深的缓冲,是同一个数。
|
||
*
|
||
* 两个档位:`low` 低延迟——够吃掉几百毫秒的到达抖动(模拟 60 ms RTT ± 16 ms、丢包重传停顿的链路上
|
||
* 1 s 都不卡,2 s 留一倍余量);`smooth` 流畅——链路差(RTT 高、重传多、带宽勉强够码率)时用,
|
||
* 多 3 s 延迟换零 waiting。低延迟档卡了会自动升到流畅档。
|
||
* HLS 来的流(fMP4 / TS)另算:服务端是整个分片到一坨,缓冲至少得盖住一个分片加取片抖动
|
||
* (B 站 hls_fmp4 分片 1–3 s),低延迟档给 4 s。
|
||
* 直连 CDN 的流不走这里(CDN 自带 GOP 缓存,mpegts.js 自己的追帧参数够用)。
|
||
*/
|
||
export interface LiveBufferPolicy {
|
||
/** 目标缓冲 = 中转延迟,秒 */
|
||
target: number
|
||
/** 落后到这以上用倍速慢慢追回目标(不跳,跳一次会把画面顿一下) */
|
||
max: number
|
||
/** 落后到这以上(长时间暂停、后台标签页)直接跳到目标位置 */
|
||
hard: number
|
||
/** 追帧倍速 */
|
||
rate: number
|
||
}
|
||
|
||
export type LatencyProfile = 'low' | 'smooth'
|
||
|
||
/** 按 tag 到达的流(FLV):低延迟 2 s / 流畅 5 s */
|
||
export const RELAY_PROFILES: Record<LatencyProfile, LiveBufferPolicy> = {
|
||
low: { target: 2, max: 3.5, hard: 12, rate: 1.1 },
|
||
smooth: { target: 5, max: 7, hard: 20, rate: 1.1 },
|
||
}
|
||
|
||
/** 按 HLS 分片到达的流(fMP4 / TS):低延迟也得盖住一个分片 */
|
||
export const RELAY_PROFILES_SEGMENTED: Record<LatencyProfile, LiveBufferPolicy> = {
|
||
low: { target: 4, max: 6, hard: 14, rate: 1.1 },
|
||
smooth: RELAY_PROFILES.smooth,
|
||
}
|
||
|
||
/** 该档位在该容器下的缓冲策略;容器未定按 FLV */
|
||
export function relayPolicy(profile: LatencyProfile, format: string | null | undefined): LiveBufferPolicy {
|
||
return format === 'fmp4' || format === 'mpegts' ? RELAY_PROFILES_SEGMENTED[profile] : RELAY_PROFILES[profile]
|
||
}
|
||
|
||
/** 播放器要维持 target 秒缓冲,起播快照就要 target 秒(服务端给到 target 到 target + 一个 GOP) */
|
||
export function snapshotMsFor(policy: LiveBufferPolicy): number {
|
||
return Math.round(policy.target * 1000)
|
||
}
|
||
|
||
/**
|
||
* 起播对齐后再看几秒:快照是一坨突发到达的,落地要一两秒(几 MB 在慢链路上更久),这期间缓冲还在长,
|
||
* 超了就再对齐一次;每对齐一次再多看 2 s,最多看 15 s,之后只做倍速 / 跳的稳态控制
|
||
*/
|
||
const SETTLE_MS = 3000
|
||
const SETTLE_EXTEND_MS = 2000
|
||
const SETTLE_MAX_MS = 15000
|
||
/** 缓冲比目标多出这么多才值得再对齐一次(起播阶段) */
|
||
const ALIGN_SLACK_S = 1
|
||
const TICK_MS = 250
|
||
|
||
export interface StallInfo {
|
||
/** 卡住了多久,秒 */
|
||
seconds: number
|
||
/** 卡住那一刻的缓冲余量,秒(真正的缓冲耗尽接近 0) */
|
||
bufferAhead: number
|
||
}
|
||
|
||
export interface LiveBufferHooks {
|
||
/** 稳态期间(起播对齐并稳定之后)一次 waiting → playing,告诉上层卡了多久 */
|
||
onStall?: (info: StallInfo) => void
|
||
}
|
||
|
||
function liveEdge(video: HTMLVideoElement): { start: number; end: number } | null {
|
||
const b = video.buffered
|
||
if (!b.length) return null
|
||
return { start: b.start(b.length - 1), end: b.end(b.length - 1) }
|
||
}
|
||
|
||
/**
|
||
* 把缓冲控制挂到 `<video>` 上:起播把播放位置对齐到「末尾 − target」,之后每 250 ms 看一次落后量,
|
||
* 超 `max` 倍速、超 `hard` 跳、掉队重对齐留下的缓冲空洞直接跳过。返回卸载函数。
|
||
* mpegts.js 的 `liveSync` / `liveBufferLatencyChasing` 与 fMP4 的 MediaSource 播放器都改用这一套,
|
||
* 两种容器的中转流行为一致,档位也能在播放中途改(`getPolicy` 每次 tick 现取)。
|
||
*/
|
||
export function attachLiveBufferControl(
|
||
video: HTMLVideoElement,
|
||
getPolicy: () => LiveBufferPolicy,
|
||
hooks: LiveBufferHooks = {}
|
||
): () => void {
|
||
let disposed = false
|
||
let aligned = false
|
||
let alignedAt = 0
|
||
let settleUntil = 0
|
||
let lastAlignAt = 0
|
||
let stallStartedAt: number | null = null
|
||
let stallAhead = 0
|
||
|
||
const seekTo = (t: number) => {
|
||
lastAlignAt = performance.now()
|
||
video.currentTime = t
|
||
}
|
||
|
||
const tick = () => {
|
||
if (disposed) return
|
||
const edge = liveEdge(video)
|
||
if (!edge) return
|
||
const policy = getPolicy()
|
||
const now = performance.now()
|
||
const latency = edge.end - video.currentTime
|
||
|
||
if (!aligned) {
|
||
// 起播:跳到「末尾 − target」(fMP4 的时间戳从一个很大的值起步,不跳进缓冲区根本放不起来);
|
||
// 快照还没到齐就先从有的地方放,接下来几秒里缓冲长过头了再对齐
|
||
aligned = true
|
||
alignedAt = now
|
||
settleUntil = now + SETTLE_MS
|
||
seekTo(Math.max(edge.start, edge.end - policy.target))
|
||
return
|
||
}
|
||
if (now < settleUntil) {
|
||
if (latency > policy.target + ALIGN_SLACK_S && now - lastAlignAt >= 500) {
|
||
seekTo(edge.end - policy.target)
|
||
settleUntil = Math.min(alignedAt + SETTLE_MAX_MS, now + SETTLE_EXTEND_MS)
|
||
}
|
||
return
|
||
}
|
||
|
||
// 掉队重对齐后时间戳前跳,缓冲区出现空洞:停在空洞前沿就跳到下一段的起点
|
||
const b = video.buffered
|
||
for (let i = 0; i + 1 < b.length; i++) {
|
||
if (video.currentTime >= b.end(i) - 0.3 && video.currentTime < b.start(i + 1) && video.readyState < 3) {
|
||
seekTo(b.start(i + 1) + 0.05)
|
||
return
|
||
}
|
||
}
|
||
if (video.paused) return
|
||
if (latency > policy.hard) {
|
||
seekTo(edge.end - policy.target)
|
||
if (video.playbackRate !== 1) video.playbackRate = 1
|
||
} else if (latency > policy.max) {
|
||
if (video.playbackRate !== policy.rate) video.playbackRate = policy.rate
|
||
} else if (latency <= policy.target && video.playbackRate !== 1) {
|
||
video.playbackRate = 1
|
||
}
|
||
}
|
||
|
||
const onWaiting = () => {
|
||
// 只统计稳态期间真正的缓冲耗尽;对齐 / 跳空洞引起的 seeking 不算
|
||
if (disposed || !aligned || performance.now() < settleUntil || video.seeking) return
|
||
const edge = liveEdge(video)
|
||
stallStartedAt = performance.now()
|
||
stallAhead = edge ? Math.max(0, edge.end - video.currentTime) : 0
|
||
}
|
||
const onPlaying = () => {
|
||
if (stallStartedAt == null) return
|
||
const seconds = (performance.now() - stallStartedAt) / 1000
|
||
stallStartedAt = null
|
||
hooks.onStall?.({ seconds, bufferAhead: stallAhead })
|
||
}
|
||
video.addEventListener('waiting', onWaiting)
|
||
video.addEventListener('playing', onPlaying)
|
||
const timer = setInterval(tick, TICK_MS)
|
||
return () => {
|
||
disposed = true
|
||
clearInterval(timer)
|
||
video.removeEventListener('waiting', onWaiting)
|
||
video.removeEventListener('playing', onPlaying)
|
||
}
|
||
}
|