@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
package/README.md
CHANGED
|
@@ -129,6 +129,70 @@ subscriber.on("connectionChange", (state) => {
|
|
|
129
129
|
subscriber.disconnect();
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
+
### Connection quality
|
|
133
|
+
|
|
134
|
+
There are two layers: a **preflight** check before connecting, and an **in-session** quality
|
|
135
|
+
signal while connected. Both report on a shared `"good" | "fair" | "poor" | "critical"` scale —
|
|
136
|
+
the SDK reports, you decide what to do (gate the UI, warn the user, etc.).
|
|
137
|
+
|
|
138
|
+
**Preflight (before connecting).** A fast, network-only reachability check — it spins up a
|
|
139
|
+
throwaway peer connection against public STUN, so there's no session and no cost:
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
const { quality, metrics, reasons } = await client.realtime.checkConnectivity();
|
|
143
|
+
// metrics: { transport: "udp" | "relay" | "failed", rttMs }
|
|
144
|
+
if (quality === "critical") showFallbackUI(reasons);
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**In-session quality.** While connected, the SDK derives a smoothed verdict from WebRTC stats
|
|
148
|
+
(latency, packet loss, bandwidth headroom, frame rate) and tells you which dimension is the
|
|
149
|
+
bottleneck:
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
const realtimeClient = await client.realtime.connect(stream, {
|
|
153
|
+
model,
|
|
154
|
+
onRemoteStream: (s) => { videoElement.srcObject = s; },
|
|
155
|
+
onConnectionQuality: ({ quality, limitingFactor, metrics }) => {
|
|
156
|
+
// limitingFactor: "bandwidth" | "latency" | "loss" | "stall" | "cpu" | "none"
|
|
157
|
+
// metrics: { rttMs, fps, packetLoss, upstreamJitterMs, availableUpstreamKbps, ... }
|
|
158
|
+
console.log(quality, limitingFactor);
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// also available as an event and a getter:
|
|
163
|
+
realtimeClient.on("connectionQuality", (report) => { /* ... */ });
|
|
164
|
+
realtimeClient.getConnectionQuality(); // latest report, or null before the first sample
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
**Glass-to-glass latency (opt-in, diagnostic).** Network RTT hides the dominant cost in
|
|
168
|
+
real-time video — model inference — so a session can read "good" while actually feeling laggy.
|
|
169
|
+
Set `debugQuality: true` to measure the *real* camera→display latency: the SDK stamps a pixel
|
|
170
|
+
marker into each outgoing frame and reads it back off the rendered output, surfacing **startup**
|
|
171
|
+
(`ttffMs`) and **steady-state** (`g2gMs`) latency plus end-to-end frame drops (`g2gDropRatio`).
|
|
172
|
+
When present, glass-to-glass drives the latency verdict instead of RTT.
|
|
173
|
+
|
|
174
|
+
> ⚠️ Diagnostic only. The marker is **visible** (bottom-left of the published and rendered video)
|
|
175
|
+
> and adds per-frame pixel work — don't enable it for production / end-user sessions.
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
const realtimeClient = await client.realtime.connect(stream, {
|
|
179
|
+
model,
|
|
180
|
+
debugQuality: true,
|
|
181
|
+
onConnectionQuality: ({ quality, metrics }) => {
|
|
182
|
+
console.log(metrics.ttffMs, metrics.g2gMs, metrics.g2gDropRatio);
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
For a *measured* verdict before connecting (instead of the network-only check), use the **deep
|
|
188
|
+
probe**: it briefly opens a real session with a synthetic source, measures glass-to-glass, then
|
|
189
|
+
tears it down. It requires a `model` and costs a short GPU session:
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
const probe = await client.realtime.checkConnectivity({ deep: true, model });
|
|
193
|
+
console.log(probe.quality, probe.metrics.g2gMs, probe.metrics.ttffMs);
|
|
194
|
+
```
|
|
195
|
+
|
|
132
196
|
### Async Processing (Queue API)
|
|
133
197
|
|
|
134
198
|
For video generation jobs, use the queue API to submit jobs and poll for results:
|
package/dist/index.d.ts
CHANGED
|
@@ -7,11 +7,14 @@ import { ProcessClient } from "./process/client.js";
|
|
|
7
7
|
import { JobStatus, JobStatusResponse, JobSubmitResponse, QueueJobResult, QueueSubmitAndPollOptions, QueueSubmitOptions } from "./queue/types.js";
|
|
8
8
|
import { QueueClient } from "./queue/client.js";
|
|
9
9
|
import { DecartSDKError, ERROR_CODES } from "./utils/errors.js";
|
|
10
|
-
import {
|
|
10
|
+
import { G2GMetrics } from "./realtime/observability/glass-to-glass.js";
|
|
11
11
|
import { WebRTCStats } from "./realtime/observability/webrtc-stats.js";
|
|
12
|
+
import { ConnectionQuality, ConnectionQualityLimitingFactor, ConnectionQualityMetrics, ConnectionQualityReport } from "./realtime/observability/connection-quality.js";
|
|
13
|
+
import { ClientSessionConnectionBreakdownEvent, ClientSessionConnectionBreakdownPhase, DiagnosticEvent, DiagnosticEventName, DiagnosticEvents, ReconnectEvent, VideoStallEvent } from "./realtime/observability/diagnostics.js";
|
|
12
14
|
import { ConnectionState, GenerationEndedMessage, QueuePosition, QueuePositionMessage } from "./realtime/types.js";
|
|
13
15
|
import { SetInput } from "./realtime/methods.js";
|
|
14
16
|
import { Events, RealTimeClient, RealTimeClientConnectOptions, RealTimeClientInitialState } from "./realtime/client.js";
|
|
17
|
+
import { CheckConnectivityOptions, ConnectivityMetrics, ConnectivityReport, ConnectivityTransport } from "./realtime/preflight.js";
|
|
15
18
|
import { RealTimeSubscribeClient, SubscribeEvents, SubscribeOptions } from "./realtime/subscribe-client.js";
|
|
16
19
|
import { ModelState } from "./shared/types.js";
|
|
17
20
|
import { CreateTokenOptions, CreateTokenResponse, TokensClient } from "./tokens/client.js";
|
|
@@ -62,6 +65,29 @@ declare const createDecartClient: (options?: DecartClientOptions) => {
|
|
|
62
65
|
realtime: {
|
|
63
66
|
connect: (stream: MediaStream | null, options: RealTimeClientConnectOptions) => Promise<RealTimeClient>;
|
|
64
67
|
subscribe: (options: SubscribeOptions) => Promise<RealTimeSubscribeClient>;
|
|
68
|
+
/**
|
|
69
|
+
* Check whether the user's network can support a real-time session
|
|
70
|
+
* *before* connecting — so you can gate showing the integration.
|
|
71
|
+
*
|
|
72
|
+
* Default (STUN-only): validates WebRTC reachability (UDP egress / TURN
|
|
73
|
+
* need) and approximate latency via a throwaway peer connection — no
|
|
74
|
+
* session, instant. Opt-in deep probe (`{ deep: true, model }`): briefly
|
|
75
|
+
* opens a real session with a synthetic source and measures *true*
|
|
76
|
+
* glass-to-glass latency (and end-to-end drops / upstream loss+jitter),
|
|
77
|
+
* then tears it down — accurate, but costs a short GPU session.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* // Fast, pre-session reachability check
|
|
82
|
+
* const { quality, reasons } = await client.realtime.checkConnectivity();
|
|
83
|
+
* if (quality === "critical") showFallbackUI(reasons);
|
|
84
|
+
*
|
|
85
|
+
* // Accurate, measured glass-to-glass verdict
|
|
86
|
+
* const probe = await client.realtime.checkConnectivity({ deep: true, model: models.realtime("mirage") });
|
|
87
|
+
* console.log(probe.metrics.g2gMs);
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
checkConnectivity: (options?: CheckConnectivityOptions) => Promise<ConnectivityReport>;
|
|
65
91
|
};
|
|
66
92
|
/**
|
|
67
93
|
* Client for synchronous image generation.
|
|
@@ -149,4 +175,4 @@ declare const createDecartClient: (options?: DecartClientOptions) => {
|
|
|
149
175
|
files: FilesClient;
|
|
150
176
|
};
|
|
151
177
|
//#endregion
|
|
152
|
-
export { type CanonicalModel, type ClientSessionConnectionBreakdownEvent, type ClientSessionConnectionBreakdownPhase, type ConnectionState, type CreateTokenOptions, type CreateTokenResponse, type CustomModelDefinition, DecartClientOptions, type DecartSDKError, type DiagnosticEvent, type DiagnosticEventName, type DiagnosticEvents, ERROR_CODES, type FileInput, type FileReference, type FileUploadInput, type FilesClient, type GenerationEndedMessage, type ImageModelDefinition, type ImageModels, type JobStatus, type JobStatusResponse, type JobSubmitResponse, type ListedModelDefinition, type LogLevel, type Logger, type Model, type ModelDefinition, type ModelKind, type ModelState, type ProcessClient, type ProcessOptions, type QueueClient, type QueueJobResult, type QueuePosition, type QueuePositionMessage, type QueueSubmitAndPollOptions, type QueueSubmitOptions, type ReactNativeFile, type RealTimeClient, type RealTimeClientConnectOptions, type RealTimeClientInitialState, type Events as RealTimeEvents, type RealTimeModels, type RealTimeSubscribeClient, type ReconnectEvent, type SetInput, type SubscribeEvents, type SubscribeOptions, type TokensClient, type UploadFileOptions, type VideoModelDefinition, type VideoModels, type VideoStallEvent, type WebRTCStats, createConsoleLogger, createDecartClient, isCanonicalModel, isImageModel, isModel, isRealtimeModel, isVideoModel, listModels, modelAliases, models, noopLogger, resolveCanonicalModelAlias, resolveModelAlias };
|
|
178
|
+
export { type CanonicalModel, type CheckConnectivityOptions, type ClientSessionConnectionBreakdownEvent, type ClientSessionConnectionBreakdownPhase, type ConnectionQuality, type ConnectionQualityLimitingFactor, type ConnectionQualityMetrics, type ConnectionQualityReport, type ConnectionState, type ConnectivityMetrics, type ConnectivityReport, type ConnectivityTransport, type CreateTokenOptions, type CreateTokenResponse, type CustomModelDefinition, DecartClientOptions, type DecartSDKError, type DiagnosticEvent, type DiagnosticEventName, type DiagnosticEvents, ERROR_CODES, type FileInput, type FileReference, type FileUploadInput, type FilesClient, type G2GMetrics, type GenerationEndedMessage, type ImageModelDefinition, type ImageModels, type JobStatus, type JobStatusResponse, type JobSubmitResponse, type ListedModelDefinition, type LogLevel, type Logger, type Model, type ModelDefinition, type ModelKind, type ModelState, type ProcessClient, type ProcessOptions, type QueueClient, type QueueJobResult, type QueuePosition, type QueuePositionMessage, type QueueSubmitAndPollOptions, type QueueSubmitOptions, type ReactNativeFile, type RealTimeClient, type RealTimeClientConnectOptions, type RealTimeClientInitialState, type Events as RealTimeEvents, type RealTimeModels, type RealTimeSubscribeClient, type ReconnectEvent, type SetInput, type SubscribeEvents, type SubscribeOptions, type TokensClient, type UploadFileOptions, type VideoModelDefinition, type VideoModels, type VideoStallEvent, type WebRTCStats, createConsoleLogger, createDecartClient, isCanonicalModel, isImageModel, isModel, isRealtimeModel, isVideoModel, listModels, modelAliases, models, noopLogger, resolveCanonicalModelAlias, resolveModelAlias };
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createQueueClient } from "./queue/client.js";
|
|
|
5
5
|
import { isCanonicalModel, isImageModel, isModel, isRealtimeModel, isVideoModel, listModels, modelAliases, models, resolveCanonicalModelAlias, resolveModelAlias } from "./shared/model.js";
|
|
6
6
|
import { createConsoleLogger, noopLogger } from "./utils/logger.js";
|
|
7
7
|
import { createRealTimeClient } from "./realtime/client.js";
|
|
8
|
+
import { createPreflight } from "./realtime/preflight.js";
|
|
8
9
|
import { createRealTimeSubscribeClient } from "./realtime/subscribe-client.js";
|
|
9
10
|
import { createTokensClient } from "./tokens/client.js";
|
|
10
11
|
import { readEnv } from "./utils/env.js";
|
|
@@ -78,6 +79,10 @@ const createDecartClient = (options = {}) => {
|
|
|
78
79
|
integration,
|
|
79
80
|
logger
|
|
80
81
|
});
|
|
82
|
+
const preflight = createPreflight({
|
|
83
|
+
logger,
|
|
84
|
+
connect: realtimePublish.connect
|
|
85
|
+
});
|
|
81
86
|
const process = createProcessClient({
|
|
82
87
|
baseUrl,
|
|
83
88
|
apiKey: apiKey || "",
|
|
@@ -101,7 +106,8 @@ const createDecartClient = (options = {}) => {
|
|
|
101
106
|
return {
|
|
102
107
|
realtime: {
|
|
103
108
|
connect: realtimePublish.connect,
|
|
104
|
-
subscribe: realtimeSubscribe.subscribe
|
|
109
|
+
subscribe: realtimeSubscribe.subscribe,
|
|
110
|
+
checkConnectivity: preflight.checkConnectivity
|
|
105
111
|
},
|
|
106
112
|
process,
|
|
107
113
|
queue,
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { CustomModelDefinition, ModelDefinition } from "../shared/model.js";
|
|
2
2
|
import { DecartSDKError } from "../utils/errors.js";
|
|
3
|
-
import { DiagnosticEvent } from "./observability/diagnostics.js";
|
|
4
3
|
import { WebRTCStats } from "./observability/webrtc-stats.js";
|
|
4
|
+
import { ConnectionQualityReport } from "./observability/connection-quality.js";
|
|
5
|
+
import { DiagnosticEvent } from "./observability/diagnostics.js";
|
|
5
6
|
import { ConnectionState, GenerationEnded, GenerationTick, ImageSetOptions, QueuePosition } from "./types.js";
|
|
6
7
|
import { SetInput } from "./methods.js";
|
|
7
8
|
import { z } from "zod";
|
|
@@ -17,6 +18,7 @@ declare const realTimeClientInitialStateSchema: z.ZodObject<{
|
|
|
17
18
|
}, z.core.$strip>;
|
|
18
19
|
type OnRemoteStreamFn = (stream: MediaStream) => void;
|
|
19
20
|
type OnConnectionChangeFn = (state: ConnectionState) => void;
|
|
21
|
+
type OnConnectionQualityFn = (report: ConnectionQualityReport) => void;
|
|
20
22
|
type OnQueuePositionFn = (queuePosition: QueuePosition) => void;
|
|
21
23
|
type RealTimeClientInitialState = z.infer<typeof realTimeClientInitialStateSchema>;
|
|
22
24
|
declare const realTimeClientConnectOptionsSchema: z.ZodObject<{
|
|
@@ -36,6 +38,7 @@ declare const realTimeClientConnectOptionsSchema: z.ZodObject<{
|
|
|
36
38
|
}, z.core.$strip>;
|
|
37
39
|
onRemoteStream: z.ZodCustom<OnRemoteStreamFn, OnRemoteStreamFn>;
|
|
38
40
|
onConnectionChange: z.ZodOptional<z.ZodCustom<OnConnectionChangeFn, OnConnectionChangeFn>>;
|
|
41
|
+
onConnectionQuality: z.ZodOptional<z.ZodCustom<OnConnectionQualityFn, OnConnectionQualityFn>>;
|
|
39
42
|
onQueuePosition: z.ZodOptional<z.ZodCustom<OnQueuePositionFn, OnQueuePositionFn>>;
|
|
40
43
|
initialState: z.ZodOptional<z.ZodObject<{
|
|
41
44
|
prompt: z.ZodOptional<z.ZodObject<{
|
|
@@ -54,12 +57,14 @@ declare const realTimeClientConnectOptionsSchema: z.ZodObject<{
|
|
|
54
57
|
h264: "h264";
|
|
55
58
|
vp9: "vp9";
|
|
56
59
|
}>>;
|
|
60
|
+
debugQuality: z.ZodOptional<z.ZodBoolean>;
|
|
57
61
|
}, z.core.$strip>;
|
|
58
62
|
type RealTimeClientConnectOptions = Omit<z.infer<typeof realTimeClientConnectOptionsSchema>, "model"> & {
|
|
59
63
|
model: ModelDefinition | CustomModelDefinition;
|
|
60
64
|
};
|
|
61
65
|
type Events = {
|
|
62
66
|
connectionChange: ConnectionState;
|
|
67
|
+
connectionQuality: ConnectionQualityReport;
|
|
63
68
|
queuePosition: QueuePosition;
|
|
64
69
|
error: DecartSDKError;
|
|
65
70
|
generationTick: GenerationTick;
|
|
@@ -76,6 +81,8 @@ type RealTimeClient = {
|
|
|
76
81
|
}) => Promise<void>;
|
|
77
82
|
isConnected: () => boolean;
|
|
78
83
|
getConnectionState: () => ConnectionState;
|
|
84
|
+
/** Latest interpreted connection-quality verdict, or null before any stats arrive. */
|
|
85
|
+
getConnectionQuality: () => ConnectionQualityReport | null;
|
|
79
86
|
disconnect: () => void;
|
|
80
87
|
on: <K extends keyof Events>(event: K, listener: (data: Events[K]) => void) => void;
|
|
81
88
|
off: <K extends keyof Events>(event: K, listener: (data: Events[K]) => void) => void;
|
package/dist/realtime/client.js
CHANGED
|
@@ -17,12 +17,14 @@ const realTimeClientConnectOptionsSchema = z.object({
|
|
|
17
17
|
model: modelDefinitionSchema,
|
|
18
18
|
onRemoteStream: z.custom((val) => typeof val === "function", { message: "onRemoteStream must be a function" }),
|
|
19
19
|
onConnectionChange: z.custom((val) => typeof val === "function", { message: "onConnectionChange must be a function" }).optional(),
|
|
20
|
+
onConnectionQuality: z.custom((val) => typeof val === "function", { message: "onConnectionQuality must be a function" }).optional(),
|
|
20
21
|
onQueuePosition: z.custom((val) => typeof val === "function", { message: "onQueuePosition must be a function" }).optional(),
|
|
21
22
|
initialState: realTimeClientInitialStateSchema.optional(),
|
|
22
23
|
queryParams: z.record(z.string(), z.string()).optional(),
|
|
23
24
|
mirror: z.union([z.literal("auto"), z.boolean()]).optional(),
|
|
24
25
|
resolution: z.enum(["720p", "1080p"]).optional(),
|
|
25
|
-
preferredVideoCodec: z.enum(["h264", "vp9"]).optional()
|
|
26
|
+
preferredVideoCodec: z.enum(["h264", "vp9"]).optional(),
|
|
27
|
+
debugQuality: z.boolean().optional()
|
|
26
28
|
});
|
|
27
29
|
const createRealTimeClient = (opts) => {
|
|
28
30
|
const { baseUrl, apiKey, integration } = opts;
|
|
@@ -30,7 +32,7 @@ const createRealTimeClient = (opts) => {
|
|
|
30
32
|
const connect = async (stream, options) => {
|
|
31
33
|
const parsedOptions = realTimeClientConnectOptionsSchema.safeParse(options);
|
|
32
34
|
if (!parsedOptions.success) throw parsedOptions.error;
|
|
33
|
-
const { onRemoteStream, onConnectionChange, onQueuePosition, initialState, resolution, preferredVideoCodec } = parsedOptions.data;
|
|
35
|
+
const { onRemoteStream, onConnectionChange, onConnectionQuality, onQueuePosition, initialState, resolution, preferredVideoCodec } = parsedOptions.data;
|
|
34
36
|
const mirror = parsedOptions.data.mirror ?? false;
|
|
35
37
|
let inputStream = stream ?? new MediaStream();
|
|
36
38
|
let mirroredStream;
|
|
@@ -43,6 +45,7 @@ const createRealTimeClient = (opts) => {
|
|
|
43
45
|
} catch (error) {
|
|
44
46
|
logger.warn("Failed to mirror input stream; falling back to un-mirrored input", { error: error instanceof Error ? error.message : String(error) });
|
|
45
47
|
}
|
|
48
|
+
const debugQuality = parsedOptions.data.debugQuality ?? false;
|
|
46
49
|
let session;
|
|
47
50
|
let observability;
|
|
48
51
|
try {
|
|
@@ -61,14 +64,21 @@ const createRealTimeClient = (opts) => {
|
|
|
61
64
|
integration,
|
|
62
65
|
logger,
|
|
63
66
|
onDiagnostic: (event) => emitOrBuffer("diagnostic", event),
|
|
64
|
-
onStats: (stats) => emitOrBuffer("stats", stats)
|
|
67
|
+
onStats: (stats) => emitOrBuffer("stats", stats),
|
|
68
|
+
onConnectionQuality: (report) => {
|
|
69
|
+
emitOrBuffer("connectionQuality", report);
|
|
70
|
+
onConnectionQuality?.(report);
|
|
71
|
+
},
|
|
72
|
+
debugQuality
|
|
65
73
|
});
|
|
74
|
+
if (debugQuality) inputStream = observability.attachOutgoingStream(inputStream, resolveFpsNumber(options.model.fps));
|
|
66
75
|
const safariCodec = isDesktopSafari() ? "vp8" : void 0;
|
|
67
76
|
const publishCodec = safariCodec ?? preferredVideoCodec;
|
|
68
77
|
session = new StreamSession({
|
|
69
78
|
url: `${url}?${new URLSearchParams({
|
|
70
79
|
...safariCodec ? { livekit_server_codec: safariCodec } : {},
|
|
71
80
|
...options.queryParams ?? {},
|
|
81
|
+
...debugQuality ? { pixel_latency: "1" } : {},
|
|
72
82
|
api_key: apiKey,
|
|
73
83
|
model: options.model.name,
|
|
74
84
|
...resolution ? { resolution } : {}
|
|
@@ -110,6 +120,7 @@ const createRealTimeClient = (opts) => {
|
|
|
110
120
|
...realtimeMethods(activeSession, imageToBase64),
|
|
111
121
|
isConnected: () => activeSession.isConnected(),
|
|
112
122
|
getConnectionState: () => activeSession.getConnectionState(),
|
|
123
|
+
getConnectionQuality: () => observability?.getConnectionQuality() ?? null,
|
|
113
124
|
disconnect: () => {
|
|
114
125
|
observability?.stop();
|
|
115
126
|
stop();
|
|
@@ -43,7 +43,62 @@ const REALTIME_CONFIG = {
|
|
|
43
43
|
statsMinIntervalMs: 500,
|
|
44
44
|
telemetryReportIntervalMs: 1e4,
|
|
45
45
|
telemetryUrl: "https://platform.decart.ai/api/v1/telemetry",
|
|
46
|
-
telemetryMaxItemsPerReport: 120
|
|
46
|
+
telemetryMaxItemsPerReport: 120,
|
|
47
|
+
connectionQuality: {
|
|
48
|
+
windowSamples: 5,
|
|
49
|
+
warmupSamples: 8,
|
|
50
|
+
downgradeConsecutive: 5,
|
|
51
|
+
upgradeConsecutive: 5,
|
|
52
|
+
rtt: {
|
|
53
|
+
goodMs: 150,
|
|
54
|
+
fairMs: 300,
|
|
55
|
+
poorMs: 500,
|
|
56
|
+
relayExtraMs: 100
|
|
57
|
+
},
|
|
58
|
+
glassToGlass: {
|
|
59
|
+
goodMs: 500,
|
|
60
|
+
fairMs: 900,
|
|
61
|
+
poorMs: 1500
|
|
62
|
+
},
|
|
63
|
+
ttff: {
|
|
64
|
+
goodMs: 4e3,
|
|
65
|
+
fairMs: 6e3,
|
|
66
|
+
poorMs: 1e4
|
|
67
|
+
},
|
|
68
|
+
loss: {
|
|
69
|
+
good: .001,
|
|
70
|
+
fair: .01,
|
|
71
|
+
poor: .05
|
|
72
|
+
},
|
|
73
|
+
g2gDrop: {
|
|
74
|
+
good: .001,
|
|
75
|
+
fair: .01,
|
|
76
|
+
poor: .05
|
|
77
|
+
},
|
|
78
|
+
upstream: {
|
|
79
|
+
goodRatio: 1,
|
|
80
|
+
fairRatio: .8,
|
|
81
|
+
poorRatio: .5,
|
|
82
|
+
requiredUpstreamKbps: 3500
|
|
83
|
+
},
|
|
84
|
+
stall: {
|
|
85
|
+
goodFps: 20,
|
|
86
|
+
fairFps: 12,
|
|
87
|
+
poorFps: 5
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
preflight: {
|
|
92
|
+
defaultStunUrls: ["stun:stun.l.google.com:19302"],
|
|
93
|
+
iceGatherTimeoutMs: 5e3,
|
|
94
|
+
rtt: {
|
|
95
|
+
goodMs: 150,
|
|
96
|
+
marginalMs: 300
|
|
97
|
+
},
|
|
98
|
+
active: {
|
|
99
|
+
durationMs: 12e3,
|
|
100
|
+
minSamples: 5
|
|
101
|
+
}
|
|
47
102
|
}
|
|
48
103
|
};
|
|
49
104
|
//#endregion
|
|
@@ -42,6 +42,7 @@ var MediaChannel = class {
|
|
|
42
42
|
if (track.kind !== Track.Kind.Video && track.kind !== Track.Kind.Audio) return;
|
|
43
43
|
const mediaStreamTrack = track.mediaStreamTrack;
|
|
44
44
|
if (mediaStreamTrack) {
|
|
45
|
+
if (track.kind === Track.Kind.Video) this.config.observability?.attachRemoteVideoTrack(mediaStreamTrack);
|
|
45
46
|
const tracks = this.remoteStream?.getTracks() ?? [];
|
|
46
47
|
if (!tracks.includes(mediaStreamTrack)) tracks.push(mediaStreamTrack);
|
|
47
48
|
this.remoteStream = new MediaStream(tracks);
|
|
@@ -12,7 +12,13 @@ function shouldMirrorTrack(track) {
|
|
|
12
12
|
}
|
|
13
13
|
return facingMode === "user";
|
|
14
14
|
}
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Wrap `input`'s video track so each published frame passes through `transform`.
|
|
17
|
+
* Uses Insertable Streams (frame-accurate) where supported, a canvas
|
|
18
|
+
* `captureStream` pump otherwise. No-ops (returns `input` unchanged) when there
|
|
19
|
+
* is no video track. Audio tracks pass through untouched.
|
|
20
|
+
*/
|
|
21
|
+
function createFrameTransformPump(input, opts) {
|
|
16
22
|
const [sourceVideo] = input.getVideoTracks();
|
|
17
23
|
const audioTracks = input.getAudioTracks();
|
|
18
24
|
if (!sourceVideo) return {
|
|
@@ -20,46 +26,53 @@ function createMirroredStream(input, opts) {
|
|
|
20
26
|
dispose: () => {},
|
|
21
27
|
impl: "noop"
|
|
22
28
|
};
|
|
23
|
-
if (isMediaStreamTrackProcessorSupported()) return createWithTrackProcessor(sourceVideo, audioTracks);
|
|
24
|
-
return createWithCanvas(sourceVideo, audioTracks, opts.fps);
|
|
29
|
+
if (isMediaStreamTrackProcessorSupported()) return createWithTrackProcessor(sourceVideo, audioTracks, opts.transform);
|
|
30
|
+
return createWithCanvas(sourceVideo, audioTracks, opts.fps, opts.transform);
|
|
31
|
+
}
|
|
32
|
+
function createMirroredStream(input, opts) {
|
|
33
|
+
return createFrameTransformPump(input, {
|
|
34
|
+
fps: opts.fps,
|
|
35
|
+
transform: (ctx, source, w, h) => {
|
|
36
|
+
ctx.save();
|
|
37
|
+
ctx.setTransform(-1, 0, 0, 1, w, 0);
|
|
38
|
+
ctx.drawImage(source, 0, 0, w, h);
|
|
39
|
+
ctx.restore();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
25
42
|
}
|
|
26
|
-
function createWithTrackProcessor(sourceVideo, audioTracks) {
|
|
43
|
+
function createWithTrackProcessor(sourceVideo, audioTracks, transform) {
|
|
27
44
|
const Processor = globalThis.MediaStreamTrackProcessor;
|
|
28
45
|
const Generator = globalThis.MediaStreamTrackGenerator;
|
|
29
|
-
if (!new OffscreenCanvas(1, 1).getContext("2d")) throw new Error("
|
|
46
|
+
if (!new OffscreenCanvas(1, 1).getContext("2d")) throw new Error("createFrameTransformPump: OffscreenCanvas 2D context unavailable");
|
|
30
47
|
const processor = new Processor({ track: sourceVideo });
|
|
31
48
|
const generator = new Generator({ kind: "video" });
|
|
32
49
|
let canvas = new OffscreenCanvas(1, 1);
|
|
33
50
|
let ctx = canvas.getContext("2d");
|
|
34
|
-
const
|
|
51
|
+
const pipeline = new TransformStream({ transform(frame, controller) {
|
|
35
52
|
const w = frame.displayWidth;
|
|
36
53
|
const h = frame.displayHeight;
|
|
37
54
|
if (canvas.width !== w || canvas.height !== h) {
|
|
38
55
|
canvas = new OffscreenCanvas(w, h);
|
|
39
56
|
ctx = canvas.getContext("2d");
|
|
40
57
|
}
|
|
41
|
-
let
|
|
58
|
+
let out;
|
|
42
59
|
try {
|
|
43
|
-
ctx
|
|
44
|
-
|
|
45
|
-
ctx.drawImage(frame, 0, 0, w, h);
|
|
46
|
-
ctx.restore();
|
|
47
|
-
flipped = new VideoFrame(canvas, {
|
|
60
|
+
transform(ctx, frame, w, h);
|
|
61
|
+
out = new VideoFrame(canvas, {
|
|
48
62
|
timestamp: frame.timestamp,
|
|
49
63
|
alpha: "discard"
|
|
50
64
|
});
|
|
51
|
-
controller.enqueue(
|
|
52
|
-
|
|
65
|
+
controller.enqueue(out);
|
|
66
|
+
out = void 0;
|
|
53
67
|
} finally {
|
|
54
|
-
|
|
68
|
+
out?.close();
|
|
55
69
|
frame.close();
|
|
56
70
|
}
|
|
57
71
|
} });
|
|
58
|
-
processor.readable.pipeThrough(
|
|
59
|
-
const stream = new MediaStream([generator, ...audioTracks]);
|
|
72
|
+
processor.readable.pipeThrough(pipeline).pipeTo(generator.writable).catch(() => {});
|
|
60
73
|
let disposed = false;
|
|
61
74
|
return {
|
|
62
|
-
stream,
|
|
75
|
+
stream: new MediaStream([generator, ...audioTracks]),
|
|
63
76
|
impl: "track-processor",
|
|
64
77
|
dispose: () => {
|
|
65
78
|
if (disposed) return;
|
|
@@ -68,14 +81,14 @@ function createWithTrackProcessor(sourceVideo, audioTracks) {
|
|
|
68
81
|
}
|
|
69
82
|
};
|
|
70
83
|
}
|
|
71
|
-
function createWithCanvas(sourceVideo, audioTracks, fps) {
|
|
72
|
-
if (typeof document === "undefined") throw new Error("
|
|
84
|
+
function createWithCanvas(sourceVideo, audioTracks, fps, transform) {
|
|
85
|
+
if (typeof document === "undefined") throw new Error("createFrameTransformPump requires a DOM environment (document is undefined)");
|
|
73
86
|
const canvas = document.createElement("canvas");
|
|
74
87
|
const ctx = canvas.getContext("2d");
|
|
75
|
-
if (!ctx) throw new Error("
|
|
76
|
-
if (typeof canvas.captureStream !== "function") throw new Error("
|
|
77
|
-
const [
|
|
78
|
-
if (!
|
|
88
|
+
if (!ctx) throw new Error("createFrameTransformPump: 2D canvas context unavailable");
|
|
89
|
+
if (typeof canvas.captureStream !== "function") throw new Error("createFrameTransformPump: canvas.captureStream unavailable");
|
|
90
|
+
const [outTrack] = canvas.captureStream(fps).getVideoTracks();
|
|
91
|
+
if (!outTrack) throw new Error("createFrameTransformPump: canvas.captureStream produced no video track");
|
|
79
92
|
const video = document.createElement("video");
|
|
80
93
|
video.muted = true;
|
|
81
94
|
video.playsInline = true;
|
|
@@ -90,26 +103,23 @@ function createWithCanvas(sourceVideo, audioTracks, fps) {
|
|
|
90
103
|
if (w > 0 && h > 0) {
|
|
91
104
|
if (canvas.width !== w) canvas.width = w;
|
|
92
105
|
if (canvas.height !== h) canvas.height = h;
|
|
93
|
-
ctx
|
|
94
|
-
ctx.setTransform(-1, 0, 0, 1, w, 0);
|
|
95
|
-
ctx.drawImage(video, 0, 0, w, h);
|
|
96
|
-
ctx.restore();
|
|
106
|
+
transform(ctx, video, w, h);
|
|
97
107
|
}
|
|
98
108
|
rafHandle = requestAnimationFrame(draw);
|
|
99
109
|
};
|
|
100
110
|
video.play().catch(() => {});
|
|
101
111
|
rafHandle = requestAnimationFrame(draw);
|
|
102
112
|
return {
|
|
103
|
-
stream: new MediaStream([
|
|
113
|
+
stream: new MediaStream([outTrack, ...audioTracks]),
|
|
104
114
|
impl: "canvas",
|
|
105
115
|
dispose: () => {
|
|
106
116
|
if (disposed) return;
|
|
107
117
|
disposed = true;
|
|
108
118
|
if (rafHandle !== null) cancelAnimationFrame(rafHandle);
|
|
109
|
-
|
|
119
|
+
outTrack.stop();
|
|
110
120
|
video.srcObject = null;
|
|
111
121
|
}
|
|
112
122
|
};
|
|
113
123
|
}
|
|
114
124
|
//#endregion
|
|
115
|
-
export { createMirroredStream, shouldMirrorTrack };
|
|
125
|
+
export { createFrameTransformPump, createMirroredStream, shouldMirrorTrack };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
//#region src/realtime/observability/connection-quality.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Smoothed verdict on whether the connection is good enough for the realtime
|
|
4
|
+
* pipeline, derived from the raw `WebRTCStats` the SDK already collects.
|
|
5
|
+
*
|
|
6
|
+
* Note: the bandwidth dimension relies on Chromium-only stats
|
|
7
|
+
* (`availableOutgoingBitrate`), so on Safari/Firefox the verdict reflects
|
|
8
|
+
* latency, loss, and fps only.
|
|
9
|
+
*/
|
|
10
|
+
type ConnectionQuality = "good" | "fair" | "poor" | "critical";
|
|
11
|
+
/** Which dimension pulled the verdict down to its current level. */
|
|
12
|
+
type ConnectionQualityLimitingFactor = "bandwidth" | "latency" | "loss" | "stall" | "cpu" | "none";
|
|
13
|
+
/** Human-meaningful numbers behind the verdict; the full raw stats are on the `stats` event. */
|
|
14
|
+
type ConnectionQualityMetrics = {
|
|
15
|
+
/** Round-trip time in ms, or null until measured. */
|
|
16
|
+
rttMs: number | null;
|
|
17
|
+
/**
|
|
18
|
+
* Mid-stream (steady-state) glass-to-glass latency (ms) — the real per-frame
|
|
19
|
+
* camera→display latency through the model, excluding startup. Only populated
|
|
20
|
+
* when the opt-in pixel-marker measurement is on (`connect({ debugQuality: true })`)
|
|
21
|
+
* and past warm-up; null otherwise. When present it drives the latency verdict
|
|
22
|
+
* instead of `rttMs`.
|
|
23
|
+
*/
|
|
24
|
+
g2gMs: number | null;
|
|
25
|
+
/**
|
|
26
|
+
* Time-to-first-frame (ms) — startup latency from connect to the first rendered
|
|
27
|
+
* model frame. One-shot; populated under g2g measurement once the first frame
|
|
28
|
+
* arrives. Surfaced for visibility; does not drive the live verdict (it's
|
|
29
|
+
* historical by the time a verdict exists).
|
|
30
|
+
*/
|
|
31
|
+
ttffMs: number | null;
|
|
32
|
+
/** Rendered (inbound) frames per second, or null until measured. */
|
|
33
|
+
fps: number | null;
|
|
34
|
+
/** Fraction (0–1) of our outbound packets the server reports lost, or null until measured. */
|
|
35
|
+
packetLoss: number | null;
|
|
36
|
+
/** Server's view of upstream (client→server) jitter in ms, or null. Observational. */
|
|
37
|
+
upstreamJitterMs: number | null;
|
|
38
|
+
/**
|
|
39
|
+
* End-to-end frame drop ratio (0–1) inferred from the pixel-marker seq stream.
|
|
40
|
+
* Only populated under the opt-in g2g measurement; null otherwise.
|
|
41
|
+
*/
|
|
42
|
+
g2gDropRatio: number | null;
|
|
43
|
+
/** Estimated available upstream bandwidth in kbps. Chromium-only — null on Safari/Firefox. */
|
|
44
|
+
availableUpstreamKbps: number | null;
|
|
45
|
+
};
|
|
46
|
+
type ConnectionQualityReport = {
|
|
47
|
+
quality: ConnectionQuality;
|
|
48
|
+
limitingFactor: ConnectionQualityLimitingFactor;
|
|
49
|
+
/** True while the connection ramps; the verdict is provisional. */
|
|
50
|
+
warmingUp: boolean;
|
|
51
|
+
metrics: ConnectionQualityMetrics;
|
|
52
|
+
};
|
|
53
|
+
//#endregion
|
|
54
|
+
export { ConnectionQuality, ConnectionQualityLimitingFactor, ConnectionQualityMetrics, ConnectionQualityReport };
|