@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 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 { ClientSessionConnectionBreakdownEvent, ClientSessionConnectionBreakdownPhase, DiagnosticEvent, DiagnosticEventName, DiagnosticEvents, ReconnectEvent, VideoStallEvent } from "./realtime/observability/diagnostics.js";
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;
@@ -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
- function createMirroredStream(input, opts) {
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("createMirroredStream: OffscreenCanvas 2D context unavailable");
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 transform = new TransformStream({ transform(frame, controller) {
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 flipped;
58
+ let out;
42
59
  try {
43
- ctx.save();
44
- ctx.setTransform(-1, 0, 0, 1, w, 0);
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(flipped);
52
- flipped = void 0;
65
+ controller.enqueue(out);
66
+ out = void 0;
53
67
  } finally {
54
- flipped?.close();
68
+ out?.close();
55
69
  frame.close();
56
70
  }
57
71
  } });
58
- processor.readable.pipeThrough(transform).pipeTo(generator.writable).catch(() => {});
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("createMirroredStream requires a DOM environment (document is undefined)");
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("createMirroredStream: 2D canvas context unavailable");
76
- if (typeof canvas.captureStream !== "function") throw new Error("createMirroredStream: canvas.captureStream unavailable");
77
- const [flippedTrack] = canvas.captureStream(fps).getVideoTracks();
78
- if (!flippedTrack) throw new Error("createMirroredStream: canvas.captureStream produced no video track");
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.save();
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([flippedTrack, ...audioTracks]),
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
- flippedTrack.stop();
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 };