158 lines
6.7 KiB
Dart
158 lines
6.7 KiB
Dart
/// 链路进给探针:**判断"图传那一段送过来的数据到底缺不缺"**。
|
||
///
|
||
/// 原理(不需要改原生,也不依赖 TrafficStats):
|
||
/// 直播流的 `buffered.last.end` 走在**媒体时间轴**上,它每推进 1ms,就等于
|
||
/// "收到了覆盖 1ms 媒体时长的数据"。用它除以墙钟间隔就是【进给率】:
|
||
/// feed = Δbuf / Δt → 数据到得齐不齐(丢帧、断供都会体现在这里)
|
||
/// play = Δpos / Δt → 渲染推得推不动(解码/性能问题会体现在这里,测不到则不判定)
|
||
/// 25fps 下每丢 1 帧 feed 少约 4%,所以 feed 可以直接当"码流完整度"读。
|
||
///
|
||
/// 为什么不用"积压绝对值"下结论(项目里的历史误报教训):RTP 时间戳与 ExoPlayer
|
||
/// 播放时钟存在系统性偏差,画面完全正常时积压也能涨到 4000~6000ms。
|
||
/// 这里 lagMs 只用作**粗分的第三种情况**(货到了没播 vs 根本没到货),阈值放得很宽,
|
||
/// 读数不可信时直接置 null(宁可少判一档,也不要用假数下结论)。
|
||
///
|
||
/// ⚠️ play 的输入必须是**原生未钳位的 position**(`await controller.position`):
|
||
/// video_player 的 `_updatePosition()` 会把位置钳到 `value.duration`,而 RTSP 直播的
|
||
/// duration 恒为 0 → `value.position` 永远是 0,于是 play 恒为 0%(假"渲染停滞"),
|
||
/// 且 lag = bufEnd - 0 会变成"自开播起的总时长"一路虚高。两者是同一个 bug 的两张脸。
|
||
///
|
||
/// 结论边界要说清楚:探针能证明"有没有缺数据",但缺数据的原因(空口误码 / 图传带宽 /
|
||
/// 网线接触)它分不出来;"进给完整但仍然花屏"则明确指向**内容本身坏了**,
|
||
/// 也就是摄像头编码或空口误码已经被拼进码流,这时 TCP 开关是救不了的。
|
||
enum LinkVerdict {
|
||
unknown(
|
||
'采样中',
|
||
'刚开始或读数不可信,等几秒再下结论。',
|
||
),
|
||
healthy(
|
||
'进给完整',
|
||
'数据基本按实时到齐。若画面仍花,病不在"传得不够",而在收到的码流本身有错'
|
||
'(空口误码已拼进流 / 编码)或解码层——切软解对比,或在摄像头端查 GOP。',
|
||
),
|
||
lightLoss(
|
||
'轻度丢帧',
|
||
'有丢帧但量不大:先切 TCP 看能否靠重传补回(补得回=链路在丢包但带宽够);'
|
||
'补不回就降码流。',
|
||
),
|
||
starved(
|
||
'断供',
|
||
'进给明显低于实时:链路吞吐不足或严重丢包,指向图传空口段。'
|
||
'APP 侧只能降码流(子码流 / 摄像头端调低码率、缩短 GOP),TCP 只会更卡。',
|
||
),
|
||
decoderStalled(
|
||
'渲染停滞',
|
||
'缓冲里还有货但播不动:解码/渲染跟不上,不是链路问题。'
|
||
'切软解或降码流对比。(仅在 position 读数可信时才会给出这一档)',
|
||
),
|
||
backlogGrowing(
|
||
'积压增长',
|
||
'缓冲末尾推进快于墙钟,积压持续变大(端到端延迟会一路上涨):媒体时间轴/推流偏差,'
|
||
'或播放侧跟不上。RTSP 是不可 seek 的直播源,ExoPlayer 不会丢旧数据追实时边缘,'
|
||
'所以唯一的止血办法是把 maxBufferMs 压小(Loader 到 max 就不许再囤)。',
|
||
);
|
||
|
||
const LinkVerdict(this.label, this.advice);
|
||
final String label;
|
||
final String advice;
|
||
}
|
||
|
||
/// 一次探针读数。
|
||
class RtspLinkSnapshot {
|
||
const RtspLinkSnapshot({
|
||
required this.windowMs,
|
||
required this.feed,
|
||
required this.play,
|
||
required this.lagMs,
|
||
required this.verdict,
|
||
this.declaredKbps,
|
||
});
|
||
|
||
final int windowMs;
|
||
|
||
/// 数据进给率(1.0 = 完全按实时到达)。
|
||
final double feed;
|
||
|
||
/// 渲染推进率(1.0 = 画面按实时在走)。null = 本流/本机型拿不到可信的 position,
|
||
/// 这一档直接不参与判定(绝不能再拿它当 0% 看)。
|
||
final double? play;
|
||
|
||
/// 缓冲末尾与播放位置之差(仅作粗分依据,不当延迟结论用)。null = 不可信。
|
||
final int? lagMs;
|
||
|
||
final LinkVerdict verdict;
|
||
|
||
/// ONVIF Profile 声明的码率(kbps),设备没报则为 null。
|
||
final int? declaredKbps;
|
||
|
||
/// 估算丢帧率(%):feed 低于 1 的部分。
|
||
int get estLossPct => ((1 - feed) * 100).clamp(0, 100).round();
|
||
|
||
/// 按"每 ms 媒体时长对应等量字节"估算的实际接收速率(kbps)。
|
||
/// 仅在有声明码率时给出,属于估算而非计量。
|
||
int? get estReceivedKbps =>
|
||
declaredKbps == null ? null : (declaredKbps! * feed).round();
|
||
|
||
/// 一行数字摘要(显示在链路条上方)。
|
||
String get detail {
|
||
final double? p = play;
|
||
final int? lag = lagMs;
|
||
final String base =
|
||
'进给 ${(feed * 100).toStringAsFixed(0)}%'
|
||
' · 渲染 ${p == null ? '—' : '${(p * 100).toStringAsFixed(0)}%'}'
|
||
' · 积压 ${lag == null ? '—' : '${lag}ms'}';
|
||
final int? kbps = estReceivedKbps;
|
||
if (kbps == null) return base;
|
||
return '$base · 实收≈${(kbps / 1000).toStringAsFixed(2)}M'
|
||
'/声明${(declaredKbps! / 1000).toStringAsFixed(2)}M';
|
||
}
|
||
}
|
||
|
||
/// 用两个相邻采样的增量算出探针读数。窗口不足或读数可疑时返回 null(不判定)。
|
||
///
|
||
/// [dPosMs] / [lagMs] 传 null 表示调用方确认这两个读数不可信(比如 position 拿不到),
|
||
/// 此时仅依 feed 判定,不再给出 `渲染停滞`。
|
||
RtspLinkSnapshot? evaluateLinkSnapshot({
|
||
required int dtMs,
|
||
int? dPosMs,
|
||
required int dBufMs,
|
||
int? lagMs,
|
||
int? declaredKbps,
|
||
}) {
|
||
// 第一个窗口没有增量;窗口太短的话时间戳抖动会盖过信号。
|
||
if (dtMs < 1500) return null;
|
||
// 缓冲末尾回退:重拉流/时间戳重置,这一轮作废。
|
||
if (dBufMs < -50) return null;
|
||
final double feed = (dBufMs / dtMs).clamp(0.0, 3.0);
|
||
final double? play = dPosMs == null ? null : (dPosMs / dtMs).clamp(0.0, 3.0);
|
||
final int? lag = lagMs;
|
||
|
||
// 关键分叉:feed 掉下来了,到底是"没到货"还是"货压着没播"?
|
||
// 手里还有明显积压却播不动 → 判解码/渲染;积压快见底 → 判链路断供。
|
||
// 没有可信的 position / lag 读数时,这一分叉直接不走(宁可少判一档)。
|
||
final bool renderStalled = play != null && play < 0.60;
|
||
final bool nearlyEmpty = lag != null && lag < 400;
|
||
|
||
final LinkVerdict verdict;
|
||
if (renderStalled && lag != null && !nearlyEmpty) {
|
||
verdict = LinkVerdict.decoderStalled;
|
||
} else if (feed > 1.08) {
|
||
verdict = LinkVerdict.backlogGrowing;
|
||
} else if (feed < 0.85) {
|
||
verdict = LinkVerdict.starved;
|
||
} else if (feed < 0.97) {
|
||
verdict = LinkVerdict.lightLoss;
|
||
} else {
|
||
verdict = LinkVerdict.healthy;
|
||
}
|
||
|
||
return RtspLinkSnapshot(
|
||
windowMs: dtMs,
|
||
feed: feed,
|
||
play: play,
|
||
lagMs: lagMs,
|
||
verdict: verdict,
|
||
declaredKbps: declaredKbps,
|
||
);
|
||
}
|