Files
flutterApp/lib/features/v2/local_camera/data/rtsp_link_probe.dart

158 lines
6.7 KiB
Dart
Raw 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.

/// 链路进给探针:**判断"图传那一段送过来的数据到底缺不缺"**。
///
/// 原理(不需要改原生,也不依赖 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,
);
}