@decartai/sdk 0.1.14 → 0.1.16
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 +24 -9
- package/dist/create-client.js +102 -0
- package/dist/files/types.js +4 -1
- package/dist/package.js +4 -0
- package/dist/process/types.d.ts +1 -1
- package/dist/realtime/browser/frame-metadata-diagnostics.js +90 -0
- package/dist/realtime/browser/frame-metadata-worker.js +2 -0
- package/dist/realtime/browser/mirror-stream.js +1 -1
- package/dist/realtime/browser/preflight.js +6 -7
- package/dist/realtime/browser/prepare-connection.js +27 -9
- package/dist/realtime/client.js +9 -0
- package/dist/realtime/config-realtime.js +51 -0
- package/dist/realtime/livekit.js +1 -1
- package/dist/realtime/media-channel.js +28 -6
- package/dist/realtime/methods.js +4 -0
- package/dist/realtime/observability/connection-quality.d.ts +3 -3
- package/dist/realtime/observability/connection-quality.js +3 -1
- package/dist/realtime/observability/realtime-observability.js +2 -16
- package/dist/realtime/observability/webrtc-stats.d.ts +3 -3
- package/dist/realtime/signaling-channel.js +3 -1
- package/dist/realtime/stream-session.js +4 -2
- package/dist/realtime/subscribe-client.js +1 -1
- package/dist/shared/model.d.ts +1 -1
- package/dist/shared/model.js +28 -1
- package/dist/shared/types.d.ts +1 -1
- package/dist/shared/types.js +6 -0
- package/dist/utils/errors.js +1 -1
- package/dist/utils/media.js +1 -1
- package/dist/utils/user-agent.js +1 -1
- package/dist/version.js +9 -4
- package/package.json +1 -1
- package/dist/realtime/browser/glass-to-glass-diagnostics.js +0 -27
- package/dist/realtime/browser/glass-to-glass.js +0 -212
- package/dist/realtime/browser/pixel-marker.js +0 -144
|
@@ -1,212 +0,0 @@
|
|
|
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/browser/glass-to-glass.ts
|
|
4
|
-
/** Bound on in-flight seqs; a seq that ages out unmatched is an end-to-end drop. Cf. server `_MAX_PENDING`. */
|
|
5
|
-
const MAX_PENDING = 256;
|
|
6
|
-
/** Rolling window for the latency percentiles (≈10s at 30fps). */
|
|
7
|
-
const LATENCY_WINDOW = 300;
|
|
8
|
-
/** Rolling window of delivered/dropped outcomes for the drop ratio. */
|
|
9
|
-
const OUTCOME_WINDOW = 300;
|
|
10
|
-
/** Don't report a drop ratio until this many outcomes exist (head-of-stream frames are still in flight). */
|
|
11
|
-
const DROP_MIN_OUTCOMES = 30;
|
|
12
|
-
/** Discard implausible deltas (clock weirdness, seq wrap collisions). */
|
|
13
|
-
const MAX_PLAUSIBLE_MS = 6e4;
|
|
14
|
-
/**
|
|
15
|
-
* After the first frame, ignore this long before counting steady-state samples.
|
|
16
|
-
* The first frames after a cold start run slow while the pipeline warms; folding
|
|
17
|
-
* them into the mid-stream median would inflate it. (TTFF still captures the
|
|
18
|
-
* first frame.)
|
|
19
|
-
*/
|
|
20
|
-
const MID_STREAM_WARMUP_MS = 2e3;
|
|
21
|
-
/**
|
|
22
|
-
* Matches outgoing stamp times to incoming render times. Shared by the stamp
|
|
23
|
-
* pump (writer) and the marker reader (matcher); owned by `RealtimeObservability`.
|
|
24
|
-
*
|
|
25
|
-
* Tracks two latencies: TTFF (start → first frame) and mid-stream median (steady
|
|
26
|
-
* state, after a warm-up). Call `markStart()` at the beginning of each connect
|
|
27
|
-
* attempt so TTFF measures the full setup→first-frame wait.
|
|
28
|
-
*/
|
|
29
|
-
var SeqTracker = class {
|
|
30
|
-
stampTimes = /* @__PURE__ */ new Map();
|
|
31
|
-
latencies = [];
|
|
32
|
-
/** true = delivered (matched), false = dropped (aged out unmatched). */
|
|
33
|
-
outcomes = [];
|
|
34
|
-
nextSeq = 0;
|
|
35
|
-
startMs = null;
|
|
36
|
-
firstMatchMs = null;
|
|
37
|
-
ttffMs = null;
|
|
38
|
-
/** Mark the start of a connect attempt; resets measurement state. TTFF is measured from here. */
|
|
39
|
-
markStart(nowMs) {
|
|
40
|
-
this.reset();
|
|
41
|
-
this.startMs = nowMs;
|
|
42
|
-
}
|
|
43
|
-
/** Allocate the next seq for an outgoing frame and record its stamp time. Returns the 16-bit seq. */
|
|
44
|
-
stampNext(nowMs) {
|
|
45
|
-
const seq = this.nextSeq & 65535;
|
|
46
|
-
this.nextSeq = this.nextSeq + 1 & 65535;
|
|
47
|
-
this.stampTimes.set(seq, nowMs);
|
|
48
|
-
if (this.stampTimes.size > MAX_PENDING) {
|
|
49
|
-
const oldest = this.stampTimes.keys().next();
|
|
50
|
-
if (!oldest.done) {
|
|
51
|
-
this.stampTimes.delete(oldest.value);
|
|
52
|
-
if (this.isPastWarmup(nowMs)) this.recordOutcome(false);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
return seq;
|
|
56
|
-
}
|
|
57
|
-
/** Match a seq read off an inbound rendered frame. Ignores unknown/duplicate seqs. */
|
|
58
|
-
recordInbound(seq, nowMs) {
|
|
59
|
-
const stampedAt = this.stampTimes.get(seq);
|
|
60
|
-
if (stampedAt === void 0) return;
|
|
61
|
-
this.stampTimes.delete(seq);
|
|
62
|
-
const g2g = nowMs - stampedAt;
|
|
63
|
-
if (g2g < 0 || g2g > MAX_PLAUSIBLE_MS) return;
|
|
64
|
-
if (this.firstMatchMs === null) {
|
|
65
|
-
this.firstMatchMs = nowMs;
|
|
66
|
-
if (this.startMs !== null) this.ttffMs = nowMs - this.startMs;
|
|
67
|
-
for (const [key, stampTime] of this.stampTimes) if (stampTime < stampedAt) this.stampTimes.delete(key);
|
|
68
|
-
else break;
|
|
69
|
-
}
|
|
70
|
-
if (!this.isPastWarmup(nowMs)) return;
|
|
71
|
-
this.latencies.push(g2g);
|
|
72
|
-
if (this.latencies.length > LATENCY_WINDOW) this.latencies.shift();
|
|
73
|
-
this.recordOutcome(true);
|
|
74
|
-
}
|
|
75
|
-
snapshot() {
|
|
76
|
-
const sorted = [...this.latencies].sort((a, b) => a - b);
|
|
77
|
-
const n = sorted.length;
|
|
78
|
-
const medianMs = n === 0 ? null : n % 2 === 0 ? Math.round((sorted[n / 2 - 1] + sorted[n / 2]) / 2) : Math.round(sorted[(n - 1) / 2]);
|
|
79
|
-
const p90Ms = n === 0 ? null : Math.round(sorted[Math.min(n - 1, Math.floor(.9 * n))]);
|
|
80
|
-
let dropRatio = null;
|
|
81
|
-
if (this.outcomes.length >= DROP_MIN_OUTCOMES) dropRatio = this.outcomes.reduce((acc, delivered) => acc + (delivered ? 0 : 1), 0) / this.outcomes.length;
|
|
82
|
-
return {
|
|
83
|
-
ttffMs: this.ttffMs,
|
|
84
|
-
medianMs,
|
|
85
|
-
p90Ms,
|
|
86
|
-
sampleCount: n,
|
|
87
|
-
dropRatio
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
/** Clear measurement state. Keeps `nextSeq` monotonic to avoid stale collisions. */
|
|
91
|
-
reset() {
|
|
92
|
-
this.stampTimes.clear();
|
|
93
|
-
this.latencies.length = 0;
|
|
94
|
-
this.outcomes.length = 0;
|
|
95
|
-
this.startMs = null;
|
|
96
|
-
this.firstMatchMs = null;
|
|
97
|
-
this.ttffMs = null;
|
|
98
|
-
}
|
|
99
|
-
isPastWarmup(nowMs) {
|
|
100
|
-
return this.firstMatchMs !== null && nowMs >= this.firstMatchMs + MID_STREAM_WARMUP_MS;
|
|
101
|
-
}
|
|
102
|
-
recordOutcome(delivered) {
|
|
103
|
-
this.outcomes.push(delivered);
|
|
104
|
-
if (this.outcomes.length > OUTCOME_WINDOW) this.outcomes.shift();
|
|
105
|
-
}
|
|
106
|
-
};
|
|
107
|
-
/**
|
|
108
|
-
* Wrap `input` so every published video frame carries a fresh marker (drawn into
|
|
109
|
-
* the bottom-left band). Built on the shared frame-transform pump; no-ops when
|
|
110
|
-
* there's no video track or the frame is too small to hold the marker.
|
|
111
|
-
*/
|
|
112
|
-
function createStampPump(input, opts) {
|
|
113
|
-
const { tracker, fps } = opts;
|
|
114
|
-
const stampIntervalMs = 1e3 / fps;
|
|
115
|
-
let lastStampMs = 0;
|
|
116
|
-
let currentSeq = null;
|
|
117
|
-
return createFrameTransformPump(input, {
|
|
118
|
-
fps,
|
|
119
|
-
transform: (ctx, source, w, h) => {
|
|
120
|
-
ctx.drawImage(source, 0, 0, w, h);
|
|
121
|
-
if (w < MIN_MARKER_WIDTH || h < 32) return;
|
|
122
|
-
const now = performance.now();
|
|
123
|
-
if (currentSeq === null || now - lastStampMs >= stampIntervalMs - 1) {
|
|
124
|
-
currentSeq = tracker.stampNext(now);
|
|
125
|
-
lastStampMs = now;
|
|
126
|
-
}
|
|
127
|
-
const band = ctx.getImageData(0, h - 32, w, 32);
|
|
128
|
-
stamp(band, currentSeq);
|
|
129
|
-
ctx.putImageData(band, 0, h - 32);
|
|
130
|
-
}
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* Drives `onFrame` once per rendered video frame. Prefers
|
|
135
|
-
* `requestVideoFrameCallback` (fires per decoded frame) over `requestAnimationFrame`
|
|
136
|
-
* (fires at display refresh — ~2× the work on a 30fps stream shown at 60Hz).
|
|
137
|
-
* The `typeof` guard keeps the rAF fallback for browsers that lack rVFC.
|
|
138
|
-
*/
|
|
139
|
-
function createFrameScheduler(video, onFrame) {
|
|
140
|
-
const supportsRvfc = typeof video.requestVideoFrameCallback === "function";
|
|
141
|
-
let handle = null;
|
|
142
|
-
let running = false;
|
|
143
|
-
const schedule = () => {
|
|
144
|
-
if (!running) return;
|
|
145
|
-
handle = supportsRvfc ? video.requestVideoFrameCallback(tick) : requestAnimationFrame(tick);
|
|
146
|
-
};
|
|
147
|
-
const tick = () => {
|
|
148
|
-
if (!running) return;
|
|
149
|
-
onFrame();
|
|
150
|
-
schedule();
|
|
151
|
-
};
|
|
152
|
-
return {
|
|
153
|
-
start: () => {
|
|
154
|
-
if (running) return;
|
|
155
|
-
running = true;
|
|
156
|
-
schedule();
|
|
157
|
-
},
|
|
158
|
-
stop: () => {
|
|
159
|
-
running = false;
|
|
160
|
-
if (handle === null) return;
|
|
161
|
-
if (supportsRvfc) video.cancelVideoFrameCallback(handle);
|
|
162
|
-
else cancelAnimationFrame(handle);
|
|
163
|
-
handle = null;
|
|
164
|
-
}
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
/**
|
|
168
|
-
* Passively read markers off the rendered remote video. Uses a hidden `<video>`
|
|
169
|
-
* fed by the same track (a track can drive multiple sinks), reading only the
|
|
170
|
-
* bottom band per rendered frame — never consumes or re-encodes the displayed track.
|
|
171
|
-
*/
|
|
172
|
-
function createMarkerReader(tracker) {
|
|
173
|
-
if (typeof document === "undefined") return {
|
|
174
|
-
attach: () => {},
|
|
175
|
-
dispose: () => {}
|
|
176
|
-
};
|
|
177
|
-
const video = document.createElement("video");
|
|
178
|
-
video.muted = true;
|
|
179
|
-
video.playsInline = true;
|
|
180
|
-
video.autoplay = true;
|
|
181
|
-
const canvas = document.createElement("canvas");
|
|
182
|
-
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
|
183
|
-
let attachedTrack = null;
|
|
184
|
-
const readFrame = () => {
|
|
185
|
-
const w = video.videoWidth;
|
|
186
|
-
const h = video.videoHeight;
|
|
187
|
-
if (w === 0 || h === 0 || !ctx) return;
|
|
188
|
-
const band = Math.min(h, MAX_MARKER_HEIGHT);
|
|
189
|
-
if (canvas.width !== w) canvas.width = w;
|
|
190
|
-
if (canvas.height !== band) canvas.height = band;
|
|
191
|
-
ctx.drawImage(video, 0, h - band, w, band, 0, 0, w, band);
|
|
192
|
-
const seq = read(ctx.getImageData(0, 0, w, band));
|
|
193
|
-
if (seq !== null) tracker.recordInbound(seq, performance.now());
|
|
194
|
-
};
|
|
195
|
-
const scheduler = createFrameScheduler(video, readFrame);
|
|
196
|
-
return {
|
|
197
|
-
attach: (track) => {
|
|
198
|
-
if (track === attachedTrack) return;
|
|
199
|
-
attachedTrack = track;
|
|
200
|
-
video.srcObject = new MediaStream([track]);
|
|
201
|
-
video.play().catch(() => {});
|
|
202
|
-
scheduler.start();
|
|
203
|
-
},
|
|
204
|
-
dispose: () => {
|
|
205
|
-
scheduler.stop();
|
|
206
|
-
attachedTrack = null;
|
|
207
|
-
video.srcObject = null;
|
|
208
|
-
}
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
//#endregion
|
|
212
|
-
export { SeqTracker, createMarkerReader, createStampPump };
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
//#region src/realtime/browser/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 };
|