Skip to content

Commit 36ce6a3

Browse files
feat: LD-6968 add detector of frozen video tracks (#25)
* feat: LD-6968 add detector of dead video tracks * fix: LD-6968 use trackId as iceCandidate * chore(release): 1.12.0-LD-6968-dead-video-track-detect.1 [skip ci] * refactor: LD-6968 do changes after code review * style: LD-6968 use `trackIdentifier` instead of `iceCandidate` * style: LD-6968 do changes after review * style: LD-6968 update reason * chore(release): 1.12.0-LD-6968-dead-video-track-detect.2 [skip ci] --------- Co-authored-by: vlprojects-bot <info@vlprojects.pro>
1 parent fdf90c2 commit 36ce6a3

File tree

6 files changed

+143
-1
lines changed

6 files changed

+143
-1
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import WebRTCIssueDetector, {
5858
NetworkMediaSyncIssueDetector,
5959
AvailableOutgoingBitrateIssueDetector,
6060
UnknownVideoDecoderImplementationDetector,
61+
FrozenVideoTrackDetector,
6162
} from 'webrtc-issue-detector';
6263

6364
const widWithDefaultConstructorArgs = new WebRTCIssueDetector();
@@ -74,6 +75,7 @@ const widWithCustomConstructorArgs = new WebRTCIssueDetector({
7475
new NetworkMediaSyncIssueDetector(),
7576
new AvailableOutgoingBitrateIssueDetector(),
7677
new UnknownVideoDecoderImplementationDetector(),
78+
new FrozenVideoTrackDetector(),
7779
],
7880
getStatsInterval: 10_000, // set custom stats parsing interval
7981
onIssues: (payload: IssueDetectorResult) => {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "webrtc-issue-detector",
3-
"version": "1.11.0",
3+
"version": "1.12.0-LD-6968-dead-video-track-detect.2",
44
"description": "WebRTC diagnostic tool that detects issues with network or user devices",
55
"repository": "git@github.com:VLprojects/webrtc-issue-detector.git",
66
"author": "Roman Kuzakov <roman.kuzakov@gmail.com>",

src/WebRTCIssueDetector.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
OutboundNetworkIssueDetector,
2424
QualityLimitationsIssueDetector,
2525
UnknownVideoDecoderImplementationDetector,
26+
FrozenVideoTrackDetector,
2627
} from './detectors';
2728
import { CompositeRTCStatsParser, RTCStatsParser } from './parser';
2829
import createLogger from './utils/logger';
@@ -65,6 +66,7 @@ class WebRTCIssueDetector {
6566
new NetworkMediaSyncIssueDetector(),
6667
new AvailableOutgoingBitrateIssueDetector(),
6768
new UnknownVideoDecoderImplementationDetector(),
69+
new FrozenVideoTrackDetector(),
6870
];
6971

7072
this.networkScoresCalculator = params.networkScoresCalculator ?? new DefaultNetworkScoresCalculator();
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import {
2+
IssueDetectorResult,
3+
IssueReason,
4+
IssueType,
5+
ParsedInboundVideoStreamStats,
6+
WebRTCStatsParsed,
7+
} from '../types';
8+
import BaseIssueDetector from './BaseIssueDetector';
9+
10+
interface FrozenVideoTrackDetectorParams {
11+
timeoutMs?: number;
12+
framesDroppedThreshold?: number;
13+
}
14+
15+
class FrozenVideoTrackDetector extends BaseIssueDetector {
16+
readonly #lastMarkedAt = new Map<string, number>();
17+
18+
readonly #timeoutMs: number;
19+
20+
readonly #framesDroppedThreshold: number;
21+
22+
constructor(params: FrozenVideoTrackDetectorParams = {}) {
23+
super();
24+
this.#timeoutMs = params.timeoutMs ?? 10_000;
25+
this.#framesDroppedThreshold = params.framesDroppedThreshold ?? 0.5;
26+
}
27+
28+
performDetection(data: WebRTCStatsParsed): IssueDetectorResult {
29+
const { connection: { id: connectionId } } = data;
30+
const issues = this.processData(data);
31+
this.setLastProcessedStats(connectionId, data);
32+
return issues;
33+
}
34+
35+
private processData(data: WebRTCStatsParsed): IssueDetectorResult {
36+
const { connection: { id: connectionId } } = data;
37+
const previousStats = this.getLastProcessedStats(connectionId);
38+
const issues: IssueDetectorResult = [];
39+
40+
if (!previousStats) {
41+
return issues;
42+
}
43+
44+
const { video: { inbound: newInbound } } = data;
45+
const { video: { inbound: prevInbound } } = previousStats;
46+
47+
const mapByTrackId = (items: ParsedInboundVideoStreamStats[]) => new Map<string, ParsedInboundVideoStreamStats>(
48+
items.map((item) => [item.track.trackIdentifier, item] as const),
49+
);
50+
51+
const newInboundByTrackId = mapByTrackId(newInbound);
52+
const prevInboundByTrackId = mapByTrackId(prevInbound);
53+
const unvisitedTrackIds = new Set(this.#lastMarkedAt.keys());
54+
55+
Array.from(newInboundByTrackId.entries()).forEach(([trackId, newInboundItem]) => {
56+
unvisitedTrackIds.delete(trackId);
57+
58+
const prevInboundItem = prevInboundByTrackId.get(trackId);
59+
if (!prevInboundItem) {
60+
return;
61+
}
62+
63+
const deltaFramesReceived = newInboundItem.framesReceived - prevInboundItem.framesReceived;
64+
const deltaFramesDropped = newInboundItem.framesDropped - prevInboundItem.framesDropped;
65+
const deltaFramesDecoded = newInboundItem.framesDecoded - prevInboundItem.framesDecoded;
66+
const ratioFramesDropped = deltaFramesDropped / deltaFramesReceived;
67+
68+
if (deltaFramesReceived === 0) {
69+
return;
70+
}
71+
72+
// We skip it when ratio is too low because it should be handled by FramesDroppedIssueDetector
73+
if (ratioFramesDropped >= this.#framesDroppedThreshold) {
74+
return;
75+
}
76+
77+
// It seems that track is alive and we can remove mark if it was marked
78+
if (deltaFramesDecoded > 0) {
79+
this.removeMarkIssue(trackId);
80+
return;
81+
}
82+
83+
const hasIssue = this.markIssue(trackId);
84+
85+
if (!hasIssue) {
86+
return;
87+
}
88+
89+
const statsSample = {
90+
framesReceived: newInboundItem.framesReceived,
91+
framesDropped: newInboundItem.framesDropped,
92+
framesDecoded: newInboundItem.framesDecoded,
93+
deltaFramesReceived,
94+
deltaFramesDropped,
95+
deltaFramesDecoded,
96+
};
97+
98+
issues.push({
99+
statsSample,
100+
type: IssueType.Stream,
101+
reason: IssueReason.FrozenVideoTrack,
102+
trackIdentifier: trackId,
103+
});
104+
});
105+
106+
// just clear unvisited tracks from memory
107+
unvisitedTrackIds.forEach((trackId) => {
108+
this.removeMarkIssue(trackId);
109+
});
110+
111+
return issues;
112+
}
113+
114+
private markIssue(trackId: string): boolean {
115+
const now = Date.now();
116+
117+
const lastMarkedAt = this.#lastMarkedAt.get(trackId);
118+
119+
if (!lastMarkedAt) {
120+
this.#lastMarkedAt.set(trackId, now);
121+
return false;
122+
}
123+
124+
if (now - lastMarkedAt < this.#timeoutMs) {
125+
return false;
126+
}
127+
128+
return true;
129+
}
130+
131+
private removeMarkIssue(trackId: string): void {
132+
this.#lastMarkedAt.delete(trackId);
133+
}
134+
}
135+
136+
export default FrozenVideoTrackDetector;

src/detectors/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ export { default as NetworkMediaSyncIssueDetector } from './NetworkMediaSyncIssu
77
export { default as OutboundNetworkIssueDetector } from './OutboundNetworkIssueDetector';
88
export { default as QualityLimitationsIssueDetector } from './QualityLimitationsIssueDetector';
99
export { default as UnknownVideoDecoderImplementationDetector } from './UnknownVideoDecoderImplementationDetector';
10+
export { default as FrozenVideoTrackDetector } from './FrozenVideoTrackDetector';

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export enum IssueReason {
8181
UnknownVideoDecoderIssue = 'unknown-video-decoder',
8282
LowInboundMOS = 'low-inbound-mean-opinion-score',
8383
LowOutboundMOS = 'low-outbound-mean-opinion-score',
84+
FrozenVideoTrack = 'frozen-video-track',
8485
}
8586

8687
export type IssuePayload = {

0 commit comments

Comments
 (0)