@decartai/sdk 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -0
- package/dist/index.d.ts +28 -2
- package/dist/index.js +7 -1
- package/dist/realtime/client.d.ts +8 -1
- package/dist/realtime/client.js +14 -3
- package/dist/realtime/config-realtime.js +56 -1
- package/dist/realtime/media-channel.js +1 -0
- package/dist/realtime/mirror-stream.js +41 -31
- package/dist/realtime/observability/connection-quality.d.ts +54 -0
- package/dist/realtime/observability/connection-quality.js +219 -0
- package/dist/realtime/observability/glass-to-glass.d.ts +41 -0
- package/dist/realtime/observability/glass-to-glass.js +229 -0
- package/dist/realtime/observability/pixel-marker.js +144 -0
- package/dist/realtime/observability/realtime-observability.js +51 -1
- package/dist/realtime/observability/telemetry-reporter.js +16 -9
- package/dist/realtime/observability/webrtc-stats.d.ts +9 -0
- package/dist/realtime/observability/webrtc-stats.js +2 -1
- package/dist/realtime/preflight.d.ts +59 -0
- package/dist/realtime/preflight.js +311 -0
- package/dist/realtime/signaling-channel.js +63 -32
- package/dist/realtime/stream-session.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { REALTIME_CONFIG } from "../config-realtime.js";
|
|
2
|
+
//#region src/realtime/observability/connection-quality.ts
|
|
3
|
+
const RANK = {
|
|
4
|
+
critical: 0,
|
|
5
|
+
poor: 1,
|
|
6
|
+
fair: 2,
|
|
7
|
+
good: 3
|
|
8
|
+
};
|
|
9
|
+
function worst(...qualities) {
|
|
10
|
+
return qualities.reduce((a, b) => RANK[a] <= RANK[b] ? a : b);
|
|
11
|
+
}
|
|
12
|
+
function scoreLowerBetter(value, good, fair, poor) {
|
|
13
|
+
if (value === null) return "good";
|
|
14
|
+
if (value <= good) return "good";
|
|
15
|
+
if (value <= fair) return "fair";
|
|
16
|
+
if (value <= poor) return "poor";
|
|
17
|
+
return "critical";
|
|
18
|
+
}
|
|
19
|
+
function scoreHigherBetter(value, good, fair, poor) {
|
|
20
|
+
if (value === null) return "good";
|
|
21
|
+
if (value >= good) return "good";
|
|
22
|
+
if (value >= fair) return "fair";
|
|
23
|
+
if (value >= poor) return "poor";
|
|
24
|
+
return "critical";
|
|
25
|
+
}
|
|
26
|
+
function extractSignals(stats) {
|
|
27
|
+
const rttSec = stats.remoteInbound?.roundTripTime ?? stats.connection.currentRoundTripTime;
|
|
28
|
+
const isRelayed = stats.connection.selectedCandidatePairs.some((pair) => pair.local.candidateType === "relay" || pair.remote.candidateType === "relay");
|
|
29
|
+
return {
|
|
30
|
+
rttMs: rttSec != null ? rttSec * 1e3 : null,
|
|
31
|
+
g2gMs: stats.glassToGlass?.medianMs ?? null,
|
|
32
|
+
ttffMs: stats.glassToGlass?.ttffMs ?? null,
|
|
33
|
+
upstreamJitterMs: stats.remoteInbound?.jitter != null ? stats.remoteInbound.jitter * 1e3 : null,
|
|
34
|
+
fractionLost: stats.remoteInbound?.fractionLost != null ? stats.remoteInbound.fractionLost / 256 : null,
|
|
35
|
+
g2gDropRatio: stats.glassToGlass?.dropRatio ?? null,
|
|
36
|
+
availableOutgoingKbps: stats.connection.availableOutgoingBitrate != null ? stats.connection.availableOutgoingBitrate / 1e3 : null,
|
|
37
|
+
fps: stats.video?.framesPerSecond ?? null,
|
|
38
|
+
freezeCountDelta: stats.video?.freezeCountDelta ?? null,
|
|
39
|
+
qualityLimitationReason: stats.outboundVideo?.qualityLimitationReason ?? null,
|
|
40
|
+
isRelayed
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** Score an already-extracted (optionally smoothed) signal set. Pure. */
|
|
44
|
+
function scoreMetrics(signals, thresholds, options = {}) {
|
|
45
|
+
const relayExtra = signals.isRelayed ? thresholds.rtt.relayExtraMs : 0;
|
|
46
|
+
const latency = signals.g2gMs != null ? scoreLowerBetter(signals.g2gMs, thresholds.glassToGlass.goodMs, thresholds.glassToGlass.fairMs, thresholds.glassToGlass.poorMs) : scoreLowerBetter(signals.rttMs, thresholds.rtt.goodMs + relayExtra, thresholds.rtt.fairMs + relayExtra, thresholds.rtt.poorMs + relayExtra);
|
|
47
|
+
const loss = scoreLowerBetter(signals.fractionLost, thresholds.loss.good, thresholds.loss.fair, thresholds.loss.poor);
|
|
48
|
+
let bandwidth = "good";
|
|
49
|
+
if (!options.skipBitrate) {
|
|
50
|
+
bandwidth = scoreHigherBetter(signals.availableOutgoingKbps != null ? signals.availableOutgoingKbps / thresholds.upstream.requiredUpstreamKbps : null, thresholds.upstream.goodRatio, thresholds.upstream.fairRatio, thresholds.upstream.poorRatio);
|
|
51
|
+
if (signals.qualityLimitationReason === "bandwidth") bandwidth = worst(bandwidth, "fair");
|
|
52
|
+
}
|
|
53
|
+
let stall = scoreHigherBetter(signals.fps, thresholds.stall.goodFps, thresholds.stall.fairFps, thresholds.stall.poorFps);
|
|
54
|
+
if (signals.freezeCountDelta != null && signals.freezeCountDelta > 0) stall = worst(stall, "fair");
|
|
55
|
+
const drop = scoreLowerBetter(signals.g2gDropRatio, thresholds.g2gDrop.good, thresholds.g2gDrop.fair, thresholds.g2gDrop.poor);
|
|
56
|
+
stall = worst(stall, drop);
|
|
57
|
+
const quality = worst(bandwidth, latency, loss, stall);
|
|
58
|
+
let limitingFactor;
|
|
59
|
+
if (quality === "good") limitingFactor = signals.qualityLimitationReason === "cpu" ? "cpu" : "none";
|
|
60
|
+
else if (bandwidth === quality) limitingFactor = "bandwidth";
|
|
61
|
+
else if (loss === quality) limitingFactor = "loss";
|
|
62
|
+
else if (latency === quality) limitingFactor = "latency";
|
|
63
|
+
else limitingFactor = "stall";
|
|
64
|
+
return {
|
|
65
|
+
quality,
|
|
66
|
+
limitingFactor
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function median(values) {
|
|
70
|
+
if (values.length === 0) return null;
|
|
71
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
72
|
+
const mid = Math.floor(sorted.length / 2);
|
|
73
|
+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
74
|
+
}
|
|
75
|
+
function minOrNull(values) {
|
|
76
|
+
return values.length === 0 ? null : Math.min(...values);
|
|
77
|
+
}
|
|
78
|
+
var RingBuffer = class {
|
|
79
|
+
values = [];
|
|
80
|
+
constructor(size) {
|
|
81
|
+
this.size = size;
|
|
82
|
+
}
|
|
83
|
+
push(value) {
|
|
84
|
+
if (value === null) return;
|
|
85
|
+
this.values.push(value);
|
|
86
|
+
if (this.values.length > this.size) this.values.shift();
|
|
87
|
+
}
|
|
88
|
+
median() {
|
|
89
|
+
return median(this.values);
|
|
90
|
+
}
|
|
91
|
+
min() {
|
|
92
|
+
return minOrNull(this.values);
|
|
93
|
+
}
|
|
94
|
+
clear() {
|
|
95
|
+
this.values = [];
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Smooths metrics over a rolling window and applies asymmetric hysteresis so the
|
|
100
|
+
* emitted level doesn't flap. `update()` returns a report only when the level or
|
|
101
|
+
* warm-up state changes; `current()` returns the latest at any time.
|
|
102
|
+
*/
|
|
103
|
+
var ConnectionQualityEvaluator = class {
|
|
104
|
+
rtt;
|
|
105
|
+
glassToGlass;
|
|
106
|
+
loss;
|
|
107
|
+
availableOutgoing;
|
|
108
|
+
fps;
|
|
109
|
+
sampleCount = 0;
|
|
110
|
+
currentLevel = null;
|
|
111
|
+
currentFactor = "none";
|
|
112
|
+
candidateLevel = null;
|
|
113
|
+
candidateCount = 0;
|
|
114
|
+
prevWarmingUp = true;
|
|
115
|
+
lastReport = null;
|
|
116
|
+
constructor(thresholds = REALTIME_CONFIG.observability.connectionQuality) {
|
|
117
|
+
this.thresholds = thresholds;
|
|
118
|
+
const w = thresholds.windowSamples;
|
|
119
|
+
this.rtt = new RingBuffer(w);
|
|
120
|
+
this.glassToGlass = new RingBuffer(w);
|
|
121
|
+
this.loss = new RingBuffer(w);
|
|
122
|
+
this.availableOutgoing = new RingBuffer(w);
|
|
123
|
+
this.fps = new RingBuffer(w);
|
|
124
|
+
}
|
|
125
|
+
/** Feed one raw stats sample. Returns a report only when the level or warm-up state changes. */
|
|
126
|
+
update(stats) {
|
|
127
|
+
this.sampleCount++;
|
|
128
|
+
const raw = extractSignals(stats);
|
|
129
|
+
this.rtt.push(raw.rttMs);
|
|
130
|
+
this.glassToGlass.push(raw.g2gMs);
|
|
131
|
+
this.loss.push(raw.fractionLost);
|
|
132
|
+
this.availableOutgoing.push(raw.availableOutgoingKbps);
|
|
133
|
+
this.fps.push(raw.fps);
|
|
134
|
+
const smoothed = {
|
|
135
|
+
...raw,
|
|
136
|
+
rttMs: this.rtt.median(),
|
|
137
|
+
g2gMs: this.glassToGlass.median(),
|
|
138
|
+
fractionLost: this.loss.median(),
|
|
139
|
+
availableOutgoingKbps: this.availableOutgoing.median(),
|
|
140
|
+
fps: this.fps.min()
|
|
141
|
+
};
|
|
142
|
+
const warmingUp = this.sampleCount < this.thresholds.warmupSamples;
|
|
143
|
+
const { quality, limitingFactor } = scoreMetrics(smoothed, this.thresholds, { skipBitrate: warmingUp });
|
|
144
|
+
const warmupJustEnded = this.prevWarmingUp && !warmingUp;
|
|
145
|
+
this.prevWarmingUp = warmingUp;
|
|
146
|
+
let changed;
|
|
147
|
+
if (warmupJustEnded) {
|
|
148
|
+
changed = this.currentLevel !== quality;
|
|
149
|
+
this.currentLevel = quality;
|
|
150
|
+
this.candidateLevel = null;
|
|
151
|
+
this.candidateCount = 0;
|
|
152
|
+
} else changed = this.applyHysteresis(quality);
|
|
153
|
+
const emitted = this.currentLevel ?? quality;
|
|
154
|
+
if (emitted === "good") this.currentFactor = smoothed.qualityLimitationReason === "cpu" ? "cpu" : "none";
|
|
155
|
+
else if (RANK[quality] <= RANK[emitted]) this.currentFactor = limitingFactor;
|
|
156
|
+
this.lastReport = {
|
|
157
|
+
quality: emitted,
|
|
158
|
+
limitingFactor: this.currentFactor,
|
|
159
|
+
warmingUp,
|
|
160
|
+
metrics: {
|
|
161
|
+
rttMs: smoothed.rttMs,
|
|
162
|
+
g2gMs: smoothed.g2gMs,
|
|
163
|
+
ttffMs: raw.ttffMs,
|
|
164
|
+
fps: smoothed.fps,
|
|
165
|
+
packetLoss: smoothed.fractionLost,
|
|
166
|
+
upstreamJitterMs: raw.upstreamJitterMs,
|
|
167
|
+
g2gDropRatio: raw.g2gDropRatio,
|
|
168
|
+
availableUpstreamKbps: smoothed.availableOutgoingKbps
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
return changed || warmupJustEnded ? this.lastReport : null;
|
|
172
|
+
}
|
|
173
|
+
current() {
|
|
174
|
+
return this.lastReport;
|
|
175
|
+
}
|
|
176
|
+
reset() {
|
|
177
|
+
this.rtt.clear();
|
|
178
|
+
this.glassToGlass.clear();
|
|
179
|
+
this.loss.clear();
|
|
180
|
+
this.availableOutgoing.clear();
|
|
181
|
+
this.fps.clear();
|
|
182
|
+
this.sampleCount = 0;
|
|
183
|
+
this.currentLevel = null;
|
|
184
|
+
this.currentFactor = "none";
|
|
185
|
+
this.candidateLevel = null;
|
|
186
|
+
this.candidateCount = 0;
|
|
187
|
+
this.prevWarmingUp = true;
|
|
188
|
+
this.lastReport = null;
|
|
189
|
+
}
|
|
190
|
+
/** Returns true if the debounced level changed this tick. */
|
|
191
|
+
applyHysteresis(raw) {
|
|
192
|
+
if (this.currentLevel === null) {
|
|
193
|
+
this.currentLevel = raw;
|
|
194
|
+
this.candidateLevel = null;
|
|
195
|
+
this.candidateCount = 0;
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
if (raw === this.currentLevel) {
|
|
199
|
+
this.candidateLevel = null;
|
|
200
|
+
this.candidateCount = 0;
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
if (raw === this.candidateLevel) this.candidateCount++;
|
|
204
|
+
else {
|
|
205
|
+
this.candidateLevel = raw;
|
|
206
|
+
this.candidateCount = 1;
|
|
207
|
+
}
|
|
208
|
+
const required = RANK[raw] < RANK[this.currentLevel] ? this.thresholds.downgradeConsecutive : this.thresholds.upgradeConsecutive;
|
|
209
|
+
if (this.candidateCount >= required) {
|
|
210
|
+
this.currentLevel = raw;
|
|
211
|
+
this.candidateLevel = null;
|
|
212
|
+
this.candidateCount = 0;
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
//#endregion
|
|
219
|
+
export { ConnectionQualityEvaluator, extractSignals, scoreLowerBetter, worst };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
//#region src/realtime/observability/glass-to-glass.d.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Aggregated glass-to-glass metrics. TTFF (startup) and mid-stream (steady
|
|
5
|
+
* state) are measured separately — they differ by an order of magnitude and the
|
|
6
|
+
* cold-start frames must not pollute the steady-state numbers.
|
|
7
|
+
*/
|
|
8
|
+
type G2GMetrics = {
|
|
9
|
+
/**
|
|
10
|
+
* Time-to-first-frame (ms): from the connect attempt start to the first
|
|
11
|
+
* rendered model output. A one-shot startup metric, ~seconds. Null until the
|
|
12
|
+
* first frame arrives.
|
|
13
|
+
*/
|
|
14
|
+
ttffMs: number | null;
|
|
15
|
+
/**
|
|
16
|
+
* Median mid-stream (steady-state) glass-to-glass latency (ms), excluding the
|
|
17
|
+
* warm-up after the first frame. Null until past warm-up. This is the
|
|
18
|
+
* per-frame responsiveness, distinct from `ttffMs`.
|
|
19
|
+
*/
|
|
20
|
+
medianMs: number | null;
|
|
21
|
+
/** p90 mid-stream glass-to-glass latency (ms), or null until past warm-up. */
|
|
22
|
+
p90Ms: number | null;
|
|
23
|
+
/** Mid-stream latency samples in the window (post-warm-up). */
|
|
24
|
+
sampleCount: number;
|
|
25
|
+
/**
|
|
26
|
+
* End-to-end frame drop ratio (0–1): seqs stamped but never rendered, over
|
|
27
|
+
* recent post-warm-up outcomes. Null until enough frames have completed the
|
|
28
|
+
* round trip — short sessions/probes may never produce it.
|
|
29
|
+
*/
|
|
30
|
+
dropRatio: number | null;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Matches outgoing stamp times to incoming render times. Shared by the stamp
|
|
34
|
+
* pump (writer) and the marker reader (matcher); owned by `RealtimeObservability`.
|
|
35
|
+
*
|
|
36
|
+
* Tracks two latencies: TTFF (start → first frame) and mid-stream median (steady
|
|
37
|
+
* state, after a warm-up). Call `markStart()` at the beginning of each connect
|
|
38
|
+
* attempt so TTFF measures the full setup→first-frame wait.
|
|
39
|
+
*/
|
|
40
|
+
//#endregion
|
|
41
|
+
export { G2GMetrics };
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { createFrameTransformPump } from "../mirror-stream.js";
|
|
2
|
+
import { MAX_MARKER_HEIGHT, MIN_MARKER_WIDTH, read, stamp } from "./pixel-marker.js";
|
|
3
|
+
//#region src/realtime/observability/glass-to-glass.ts
|
|
4
|
+
/**
|
|
5
|
+
* True glass-to-glass latency measurement for the realtime pipeline.
|
|
6
|
+
*
|
|
7
|
+
* Opt-in (the marker is visible in the output and pixel work has a cost). When
|
|
8
|
+
* enabled, the SDK stamps a monotonic sequence number into the bottom-left of
|
|
9
|
+
* every outgoing frame ({@link createStampPump}) and reads it back off the
|
|
10
|
+
* rendered remote frames ({@link createMarkerReader}); the server re-stamps the
|
|
11
|
+
* seq from input to matching output (its `pixel_latency` mode). The
|
|
12
|
+
* {@link SeqTracker} matches stamp time to render time to compute the real
|
|
13
|
+
* camera→display latency through the model, and infers end-to-end frame drops
|
|
14
|
+
* from seqs that are stamped but never rendered.
|
|
15
|
+
*
|
|
16
|
+
* The stamp pump is built on the shared `createFrameTransformPump` (Insertable
|
|
17
|
+
* Streams where available, canvas `captureStream` fallback). The reader is a
|
|
18
|
+
* passive offscreen-`<video>` tap so it never consumes or re-encodes the track
|
|
19
|
+
* the consumer displays.
|
|
20
|
+
*/
|
|
21
|
+
/** Bound on in-flight seqs; a seq that ages out unmatched is an end-to-end drop. Cf. server `_MAX_PENDING`. */
|
|
22
|
+
const MAX_PENDING = 256;
|
|
23
|
+
/** Rolling window for the latency percentiles (≈10s at 30fps). */
|
|
24
|
+
const LATENCY_WINDOW = 300;
|
|
25
|
+
/** Rolling window of delivered/dropped outcomes for the drop ratio. */
|
|
26
|
+
const OUTCOME_WINDOW = 300;
|
|
27
|
+
/** Don't report a drop ratio until this many outcomes exist (head-of-stream frames are still in flight). */
|
|
28
|
+
const DROP_MIN_OUTCOMES = 30;
|
|
29
|
+
/** Discard implausible deltas (clock weirdness, seq wrap collisions). */
|
|
30
|
+
const MAX_PLAUSIBLE_MS = 6e4;
|
|
31
|
+
/**
|
|
32
|
+
* After the first frame, ignore this long before counting steady-state samples.
|
|
33
|
+
* The first frames after a cold start run slow while the pipeline warms; folding
|
|
34
|
+
* them into the mid-stream median would inflate it. (TTFF still captures the
|
|
35
|
+
* first frame.)
|
|
36
|
+
*/
|
|
37
|
+
const MID_STREAM_WARMUP_MS = 2e3;
|
|
38
|
+
/**
|
|
39
|
+
* Matches outgoing stamp times to incoming render times. Shared by the stamp
|
|
40
|
+
* pump (writer) and the marker reader (matcher); owned by `RealtimeObservability`.
|
|
41
|
+
*
|
|
42
|
+
* Tracks two latencies: TTFF (start → first frame) and mid-stream median (steady
|
|
43
|
+
* state, after a warm-up). Call `markStart()` at the beginning of each connect
|
|
44
|
+
* attempt so TTFF measures the full setup→first-frame wait.
|
|
45
|
+
*/
|
|
46
|
+
var SeqTracker = class {
|
|
47
|
+
stampTimes = /* @__PURE__ */ new Map();
|
|
48
|
+
latencies = [];
|
|
49
|
+
/** true = delivered (matched), false = dropped (aged out unmatched). */
|
|
50
|
+
outcomes = [];
|
|
51
|
+
nextSeq = 0;
|
|
52
|
+
startMs = null;
|
|
53
|
+
firstMatchMs = null;
|
|
54
|
+
ttffMs = null;
|
|
55
|
+
/** Mark the start of a connect attempt; resets measurement state. TTFF is measured from here. */
|
|
56
|
+
markStart(nowMs) {
|
|
57
|
+
this.reset();
|
|
58
|
+
this.startMs = nowMs;
|
|
59
|
+
}
|
|
60
|
+
/** Allocate the next seq for an outgoing frame and record its stamp time. Returns the 16-bit seq. */
|
|
61
|
+
stampNext(nowMs) {
|
|
62
|
+
const seq = this.nextSeq & 65535;
|
|
63
|
+
this.nextSeq = this.nextSeq + 1 & 65535;
|
|
64
|
+
this.stampTimes.set(seq, nowMs);
|
|
65
|
+
if (this.stampTimes.size > MAX_PENDING) {
|
|
66
|
+
const oldest = this.stampTimes.keys().next();
|
|
67
|
+
if (!oldest.done) {
|
|
68
|
+
this.stampTimes.delete(oldest.value);
|
|
69
|
+
if (this.isPastWarmup(nowMs)) this.recordOutcome(false);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return seq;
|
|
73
|
+
}
|
|
74
|
+
/** Match a seq read off an inbound rendered frame. Ignores unknown/duplicate seqs. */
|
|
75
|
+
recordInbound(seq, nowMs) {
|
|
76
|
+
const stampedAt = this.stampTimes.get(seq);
|
|
77
|
+
if (stampedAt === void 0) return;
|
|
78
|
+
this.stampTimes.delete(seq);
|
|
79
|
+
const g2g = nowMs - stampedAt;
|
|
80
|
+
if (g2g < 0 || g2g > MAX_PLAUSIBLE_MS) return;
|
|
81
|
+
if (this.firstMatchMs === null) {
|
|
82
|
+
this.firstMatchMs = nowMs;
|
|
83
|
+
if (this.startMs !== null) this.ttffMs = nowMs - this.startMs;
|
|
84
|
+
for (const [key, stampTime] of this.stampTimes) if (stampTime < stampedAt) this.stampTimes.delete(key);
|
|
85
|
+
else break;
|
|
86
|
+
}
|
|
87
|
+
if (!this.isPastWarmup(nowMs)) return;
|
|
88
|
+
this.latencies.push(g2g);
|
|
89
|
+
if (this.latencies.length > LATENCY_WINDOW) this.latencies.shift();
|
|
90
|
+
this.recordOutcome(true);
|
|
91
|
+
}
|
|
92
|
+
snapshot() {
|
|
93
|
+
const sorted = [...this.latencies].sort((a, b) => a - b);
|
|
94
|
+
const n = sorted.length;
|
|
95
|
+
const medianMs = n === 0 ? null : n % 2 === 0 ? Math.round((sorted[n / 2 - 1] + sorted[n / 2]) / 2) : Math.round(sorted[(n - 1) / 2]);
|
|
96
|
+
const p90Ms = n === 0 ? null : Math.round(sorted[Math.min(n - 1, Math.floor(.9 * n))]);
|
|
97
|
+
let dropRatio = null;
|
|
98
|
+
if (this.outcomes.length >= DROP_MIN_OUTCOMES) dropRatio = this.outcomes.reduce((acc, delivered) => acc + (delivered ? 0 : 1), 0) / this.outcomes.length;
|
|
99
|
+
return {
|
|
100
|
+
ttffMs: this.ttffMs,
|
|
101
|
+
medianMs,
|
|
102
|
+
p90Ms,
|
|
103
|
+
sampleCount: n,
|
|
104
|
+
dropRatio
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/** Clear measurement state. Keeps `nextSeq` monotonic to avoid stale collisions. */
|
|
108
|
+
reset() {
|
|
109
|
+
this.stampTimes.clear();
|
|
110
|
+
this.latencies.length = 0;
|
|
111
|
+
this.outcomes.length = 0;
|
|
112
|
+
this.startMs = null;
|
|
113
|
+
this.firstMatchMs = null;
|
|
114
|
+
this.ttffMs = null;
|
|
115
|
+
}
|
|
116
|
+
isPastWarmup(nowMs) {
|
|
117
|
+
return this.firstMatchMs !== null && nowMs >= this.firstMatchMs + MID_STREAM_WARMUP_MS;
|
|
118
|
+
}
|
|
119
|
+
recordOutcome(delivered) {
|
|
120
|
+
this.outcomes.push(delivered);
|
|
121
|
+
if (this.outcomes.length > OUTCOME_WINDOW) this.outcomes.shift();
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Wrap `input` so every published video frame carries a fresh marker (drawn into
|
|
126
|
+
* the bottom-left band). Built on the shared frame-transform pump; no-ops when
|
|
127
|
+
* there's no video track or the frame is too small to hold the marker.
|
|
128
|
+
*/
|
|
129
|
+
function createStampPump(input, opts) {
|
|
130
|
+
const { tracker, fps } = opts;
|
|
131
|
+
const stampIntervalMs = 1e3 / fps;
|
|
132
|
+
let lastStampMs = 0;
|
|
133
|
+
let currentSeq = null;
|
|
134
|
+
return createFrameTransformPump(input, {
|
|
135
|
+
fps,
|
|
136
|
+
transform: (ctx, source, w, h) => {
|
|
137
|
+
ctx.drawImage(source, 0, 0, w, h);
|
|
138
|
+
if (w < MIN_MARKER_WIDTH || h < 32) return;
|
|
139
|
+
const now = performance.now();
|
|
140
|
+
if (currentSeq === null || now - lastStampMs >= stampIntervalMs - 1) {
|
|
141
|
+
currentSeq = tracker.stampNext(now);
|
|
142
|
+
lastStampMs = now;
|
|
143
|
+
}
|
|
144
|
+
const band = ctx.getImageData(0, h - 32, w, 32);
|
|
145
|
+
stamp(band, currentSeq);
|
|
146
|
+
ctx.putImageData(band, 0, h - 32);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Drives `onFrame` once per rendered video frame. Prefers
|
|
152
|
+
* `requestVideoFrameCallback` (fires per decoded frame) over `requestAnimationFrame`
|
|
153
|
+
* (fires at display refresh — ~2× the work on a 30fps stream shown at 60Hz).
|
|
154
|
+
* The `typeof` guard keeps the rAF fallback for browsers that lack rVFC.
|
|
155
|
+
*/
|
|
156
|
+
function createFrameScheduler(video, onFrame) {
|
|
157
|
+
const supportsRvfc = typeof video.requestVideoFrameCallback === "function";
|
|
158
|
+
let handle = null;
|
|
159
|
+
let running = false;
|
|
160
|
+
const schedule = () => {
|
|
161
|
+
if (!running) return;
|
|
162
|
+
handle = supportsRvfc ? video.requestVideoFrameCallback(tick) : requestAnimationFrame(tick);
|
|
163
|
+
};
|
|
164
|
+
const tick = () => {
|
|
165
|
+
if (!running) return;
|
|
166
|
+
onFrame();
|
|
167
|
+
schedule();
|
|
168
|
+
};
|
|
169
|
+
return {
|
|
170
|
+
start: () => {
|
|
171
|
+
if (running) return;
|
|
172
|
+
running = true;
|
|
173
|
+
schedule();
|
|
174
|
+
},
|
|
175
|
+
stop: () => {
|
|
176
|
+
running = false;
|
|
177
|
+
if (handle === null) return;
|
|
178
|
+
if (supportsRvfc) video.cancelVideoFrameCallback(handle);
|
|
179
|
+
else cancelAnimationFrame(handle);
|
|
180
|
+
handle = null;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Passively read markers off the rendered remote video. Uses a hidden `<video>`
|
|
186
|
+
* fed by the same track (a track can drive multiple sinks), reading only the
|
|
187
|
+
* bottom band per rendered frame — never consumes or re-encodes the displayed track.
|
|
188
|
+
*/
|
|
189
|
+
function createMarkerReader(tracker) {
|
|
190
|
+
if (typeof document === "undefined") return {
|
|
191
|
+
attach: () => {},
|
|
192
|
+
dispose: () => {}
|
|
193
|
+
};
|
|
194
|
+
const video = document.createElement("video");
|
|
195
|
+
video.muted = true;
|
|
196
|
+
video.playsInline = true;
|
|
197
|
+
video.autoplay = true;
|
|
198
|
+
const canvas = document.createElement("canvas");
|
|
199
|
+
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
|
200
|
+
let attachedTrack = null;
|
|
201
|
+
const readFrame = () => {
|
|
202
|
+
const w = video.videoWidth;
|
|
203
|
+
const h = video.videoHeight;
|
|
204
|
+
if (w === 0 || h === 0 || !ctx) return;
|
|
205
|
+
const band = Math.min(h, MAX_MARKER_HEIGHT);
|
|
206
|
+
if (canvas.width !== w) canvas.width = w;
|
|
207
|
+
if (canvas.height !== band) canvas.height = band;
|
|
208
|
+
ctx.drawImage(video, 0, h - band, w, band, 0, 0, w, band);
|
|
209
|
+
const seq = read(ctx.getImageData(0, 0, w, band));
|
|
210
|
+
if (seq !== null) tracker.recordInbound(seq, performance.now());
|
|
211
|
+
};
|
|
212
|
+
const scheduler = createFrameScheduler(video, readFrame);
|
|
213
|
+
return {
|
|
214
|
+
attach: (track) => {
|
|
215
|
+
if (track === attachedTrack) return;
|
|
216
|
+
attachedTrack = track;
|
|
217
|
+
video.srcObject = new MediaStream([track]);
|
|
218
|
+
video.play().catch(() => {});
|
|
219
|
+
scheduler.start();
|
|
220
|
+
},
|
|
221
|
+
dispose: () => {
|
|
222
|
+
scheduler.stop();
|
|
223
|
+
attachedTrack = null;
|
|
224
|
+
video.srcObject = null;
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
//#endregion
|
|
229
|
+
export { SeqTracker, createMarkerReader, createStampPump };
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
//#region src/realtime/observability/pixel-marker.ts
|
|
2
|
+
const SYNC = [
|
|
3
|
+
200,
|
|
4
|
+
50,
|
|
5
|
+
200,
|
|
6
|
+
50
|
|
7
|
+
];
|
|
8
|
+
const SYNC_LEN = SYNC.length;
|
|
9
|
+
const DATA_BITS = 16;
|
|
10
|
+
const CHECKSUM_BITS = 4;
|
|
11
|
+
/** 4 sync + 16 data + 4 checksum logical columns. */
|
|
12
|
+
const TOTAL_LOGICAL = SYNC_LEN + DATA_BITS + CHECKSUM_BITS;
|
|
13
|
+
/** Redundant logical rows, majority-voted on read. */
|
|
14
|
+
const MARKER_ROWS = 4;
|
|
15
|
+
/** Physical pixels per logical pixel when stamping (native resolution). */
|
|
16
|
+
const BLOCK_SIZE = 8;
|
|
17
|
+
/**
|
|
18
|
+
* Candidate received block sizes, ordered by likelihood (nominal 8, no transport
|
|
19
|
+
* scaling). Smaller values appear when WebRTC BWE downscales the stream; larger
|
|
20
|
+
* when the sender upscales pre-encode. Mirrors `_CANDIDATE_BLOCK_SIZES`.
|
|
21
|
+
*/
|
|
22
|
+
const CANDIDATE_BLOCK_SIZES = [
|
|
23
|
+
8,
|
|
24
|
+
4,
|
|
25
|
+
6,
|
|
26
|
+
2,
|
|
27
|
+
12,
|
|
28
|
+
10,
|
|
29
|
+
16,
|
|
30
|
+
5,
|
|
31
|
+
7,
|
|
32
|
+
14,
|
|
33
|
+
3
|
|
34
|
+
];
|
|
35
|
+
/** Smallest frame that can hold the marker at nominal block size. */
|
|
36
|
+
const MIN_MARKER_WIDTH = TOTAL_LOGICAL * BLOCK_SIZE;
|
|
37
|
+
MARKER_ROWS * BLOCK_SIZE;
|
|
38
|
+
/** Tallest the marker can be in a received frame (largest auto-detect block size). */
|
|
39
|
+
const MAX_MARKER_HEIGHT = MARKER_ROWS * Math.max(...CANDIDATE_BLOCK_SIZES);
|
|
40
|
+
/** BT.601 luma approximation (integer, matches a >=128 threshold either way). */
|
|
41
|
+
function luma(r, g, b) {
|
|
42
|
+
return 77 * r + 150 * g + 29 * b >> 8;
|
|
43
|
+
}
|
|
44
|
+
const isHigh = (v) => v >= 128;
|
|
45
|
+
/** XOR of the four 4-bit nibbles of the 16-bit seq (matches the server). */
|
|
46
|
+
function checksumNibbles(seq) {
|
|
47
|
+
let checksum = 0;
|
|
48
|
+
for (let i = 0; i < DATA_BITS; i += 4) checksum ^= seq >> i & 15;
|
|
49
|
+
return checksum;
|
|
50
|
+
}
|
|
51
|
+
/** The TOTAL_LOGICAL grayscale values for one logical row encoding `seq`. */
|
|
52
|
+
function rowValues(seq) {
|
|
53
|
+
const masked = seq & 65535;
|
|
54
|
+
const values = [...SYNC];
|
|
55
|
+
for (let i = 0; i < DATA_BITS; i++) values.push(masked >> DATA_BITS - 1 - i & 1 ? 200 : 50);
|
|
56
|
+
const checksum = checksumNibbles(masked);
|
|
57
|
+
for (let i = 0; i < CHECKSUM_BITS; i++) values.push(checksum >> CHECKSUM_BITS - 1 - i & 1 ? 200 : 50);
|
|
58
|
+
return values;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Stamp `seq` into the bottom-left of `img` as grayscale blocks (mutates in
|
|
62
|
+
* place). Returns false (no-op) if the frame is too small to hold the marker.
|
|
63
|
+
* Always stamps at BLOCK_SIZE=8, matching the server's native-resolution stamp.
|
|
64
|
+
*/
|
|
65
|
+
function stamp(img, seq) {
|
|
66
|
+
const { width, height, data } = img;
|
|
67
|
+
if (width < MIN_MARKER_WIDTH || height < 32) return false;
|
|
68
|
+
const values = rowValues(seq);
|
|
69
|
+
for (let logRow = 0; logRow < MARKER_ROWS; logRow++) {
|
|
70
|
+
const rowStart = height - (MARKER_ROWS - logRow) * BLOCK_SIZE;
|
|
71
|
+
for (let by = 0; by < BLOCK_SIZE; by++) {
|
|
72
|
+
const y = rowStart + by;
|
|
73
|
+
if (y < 0 || y >= height) continue;
|
|
74
|
+
for (let logCol = 0; logCol < TOTAL_LOGICAL; logCol++) {
|
|
75
|
+
const v = values[logCol];
|
|
76
|
+
const xStart = logCol * BLOCK_SIZE;
|
|
77
|
+
const xEnd = Math.min(xStart + BLOCK_SIZE, width);
|
|
78
|
+
for (let x = xStart; x < xEnd; x++) {
|
|
79
|
+
const o = (y * width + x) * 4;
|
|
80
|
+
data[o] = v;
|
|
81
|
+
data[o + 1] = v;
|
|
82
|
+
data[o + 2] = v;
|
|
83
|
+
data[o + 3] = 255;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
function syncMatches(rowValues) {
|
|
91
|
+
for (let i = 0; i < SYNC_LEN; i++) if (isHigh(SYNC[i]) !== isHigh(rowValues[i])) return false;
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Read the marker seq from the bottom of `img`, or null if absent/unreadable.
|
|
96
|
+
* Auto-detects the received block size so it works at any received resolution
|
|
97
|
+
* (the transport may uniformly scale the frame after the server stamped it).
|
|
98
|
+
*/
|
|
99
|
+
function read(img) {
|
|
100
|
+
const { width, height, data } = img;
|
|
101
|
+
const sample = (row, col) => {
|
|
102
|
+
const o = (row * width + col) * 4;
|
|
103
|
+
return luma(data[o], data[o + 1], data[o + 2]);
|
|
104
|
+
};
|
|
105
|
+
for (const blockSize of CANDIDATE_BLOCK_SIZES) {
|
|
106
|
+
if (width < TOTAL_LOGICAL * blockSize || height < MARKER_ROWS * blockSize) continue;
|
|
107
|
+
const seq = decodeAtBlockSize(sample, width, height, blockSize);
|
|
108
|
+
if (seq !== null) return seq;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
function decodeAtBlockSize(sample, width, height, blockSize) {
|
|
113
|
+
const half = blockSize >> 1;
|
|
114
|
+
const validRows = [];
|
|
115
|
+
for (let logRow = 0; logRow < MARKER_ROWS; logRow++) {
|
|
116
|
+
let row = height - (MARKER_ROWS - logRow) * blockSize + half;
|
|
117
|
+
row = Math.max(0, Math.min(row, height - 1));
|
|
118
|
+
const rv = [];
|
|
119
|
+
for (let logCol = 0; logCol < TOTAL_LOGICAL; logCol++) {
|
|
120
|
+
let col = logCol * blockSize + half;
|
|
121
|
+
col = Math.max(0, Math.min(col, width - 1));
|
|
122
|
+
rv.push(sample(row, col));
|
|
123
|
+
}
|
|
124
|
+
if (syncMatches(rv)) validRows.push(rv);
|
|
125
|
+
}
|
|
126
|
+
if (validRows.length === 0) return null;
|
|
127
|
+
const threshold = validRows.length / 2;
|
|
128
|
+
let seq = 0;
|
|
129
|
+
for (let i = 0; i < DATA_BITS; i++) {
|
|
130
|
+
let votes = 0;
|
|
131
|
+
for (const rv of validRows) if (isHigh(rv[SYNC_LEN + i])) votes++;
|
|
132
|
+
if (votes > threshold) seq |= 1 << DATA_BITS - 1 - i;
|
|
133
|
+
}
|
|
134
|
+
const expectedChecksum = checksumNibbles(seq);
|
|
135
|
+
let actualChecksum = 0;
|
|
136
|
+
for (let i = 0; i < CHECKSUM_BITS; i++) {
|
|
137
|
+
let votes = 0;
|
|
138
|
+
for (const rv of validRows) if (isHigh(rv[SYNC_LEN + DATA_BITS + i])) votes++;
|
|
139
|
+
if (votes > threshold) actualChecksum |= 1 << CHECKSUM_BITS - 1 - i;
|
|
140
|
+
}
|
|
141
|
+
return expectedChecksum === actualChecksum ? seq : null;
|
|
142
|
+
}
|
|
143
|
+
//#endregion
|
|
144
|
+
export { MAX_MARKER_HEIGHT, MIN_MARKER_WIDTH, read, stamp };
|