@decartai/sdk 0.1.20 → 0.1.22

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
@@ -88,6 +88,18 @@ A connected realtime session exposes an SDK `subscribeToken` once it reaches a
88
88
  connected state. Share that SDK token with viewers — `client.realtime.subscribe`
89
89
  uses it to request receive-only LiveKit credentials from Decart, then connects to
90
90
  the LiveKit room for the styled output stream. No viewer camera is required.
91
+ Browser-produced streams can include LiveKit frame metadata for glass-to-glass
92
+ latency, so viewers also need a browser runtime that can create the SDK's
93
+ frame-metadata worker; unsupported runtimes reject those tokens before joining.
94
+
95
+ > **Viewers must be on this SDK version or newer.** A browser publisher now
96
+ > advertises frame timing by default, which makes the server append a packet
97
+ > trailer to every frame in the room. Viewers on an older `@decartai/sdk`
98
+ > ignore the token's `frame_timing` flag and join without a strip worker — they
99
+ > connect successfully but decode nothing, so the video element stays black with
100
+ > no error raised. React Native viewers cannot strip trailers at all and are
101
+ > rejected with `UNSUPPORTED_PLATFORM_FEATURE`. Upgrade viewers before upgrading
102
+ > publishers.
91
103
 
92
104
  **Producer** — capture the token from the active session:
93
105
 
@@ -175,12 +187,12 @@ realtimeClient.getConnectionQuality(); // latest report, or null before the firs
175
187
  > setInterval(() => console.log(realtimeClient.getConnectionQuality()?.metrics.g2gMs), 1000);
176
188
  > ```
177
189
 
178
- **Glass-to-glass latency (opt-in, diagnostic).** Network RTT hides the dominant cost in
179
- real-time video — model inference — so a session can read "good" while actually feeling laggy.
180
- Set `debugQuality: true` to measure the *real* camera→display latency. The SDK attaches a capture
181
- timestamp using LiveKit frame metadata; the server propagates it through inference and the SDK
182
- matches it to output playout. This surfaces **startup** (`ttffMs`) and **steady-state**
183
- (`g2gMs`) latency. When present, glass-to-glass drives the latency verdict instead of RTT.
190
+ **Glass-to-glass latency.** Network RTT hides the dominant cost in real-time video — model
191
+ inference — so a session can read "good" while actually feeling laggy. In browser sessions, the SDK
192
+ automatically attaches a capture timestamp using LiveKit frame metadata when the required worker is
193
+ available; the server propagates it through inference and the SDK matches it to output playout.
194
+ This surfaces **startup** (`ttffMs`) and **steady-state** (`g2gMs`) latency. When present,
195
+ glass-to-glass drives the latency verdict instead of RTT.
184
196
 
185
197
  > Frame metadata is currently experimental in LiveKit and requires encoded-transform support.
186
198
  > It does not alter visible pixels. `g2gDropRatio` remains `null` until frame IDs are propagated
@@ -189,7 +201,6 @@ matches it to output playout. This surfaces **startup** (`ttffMs`) and **steady-
189
201
  ```typescript
190
202
  const realtimeClient = await client.realtime.connect(stream, {
191
203
  model,
192
- debugQuality: true,
193
204
  });
194
205
 
195
206
  // g2g updates every stats tick — read it from the `stats` event (the
@@ -338,9 +349,9 @@ Add them to `app.json`:
338
349
  Run `npx expo prebuild` and rebuild the native app. LiveKit does not run in Expo
339
350
  Go. Bare React Native apps must follow LiveKit's native setup instructions.
340
351
 
341
- Outgoing `mirror`, `debugQuality`, and deep connectivity preflight are browser-only
342
- and fail with `UNSUPPORTED_PLATFORM_FEATURE` on React Native. Mirror the local
343
- preview with your native video view instead.
352
+ Outgoing `mirror` and deep connectivity preflight are browser-only and fail with
353
+ `UNSUPPORTED_PLATFORM_FEATURE` on React Native. Mirror the local preview with your
354
+ native video view instead.
344
355
 
345
356
  ## Development
346
357
 
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.1.20";
2
+ var version = "0.1.22";
3
3
  //#endregion
4
4
  export { version };
@@ -52,8 +52,8 @@ const TIME_SYNC_UPDATE = "timeSyncUpdate";
52
52
  function createFrameReader(tracker) {
53
53
  let attachedTrack = null;
54
54
  const onTimeSyncUpdate = ({ timestamp, rtpTimestamp }) => {
55
- const frameMetadata = attachedTrack?.lookupFrameMetadata({ rtpTimestamp });
56
- if (frameMetadata) tracker.recordFrame(frameMetadata.userTimestamp, performance.timeOrigin + timestamp);
55
+ const frameMetadata = attachedTrack?.lookupFrameMetadata?.({ rtpTimestamp });
56
+ if (frameMetadata) tracker.recordFrame(frameMetadata.userTimestamp, timestamp);
57
57
  };
58
58
  const detach = () => {
59
59
  attachedTrack?.off(TIME_SYNC_UPDATE, onTimeSyncUpdate);
@@ -83,8 +83,27 @@ function createBrowserFrameMetadataDiagnostics() {
83
83
  dispose: () => reader.dispose()
84
84
  };
85
85
  }
86
+ const WORKER_URL = () => new URL("./frame-metadata-worker.js", import.meta.url);
86
87
  function createFrameMetadataWorker() {
87
- return new Worker(new URL("./frame-metadata-worker.js", import.meta.url));
88
+ return new Worker(WORKER_URL());
89
+ }
90
+ function isFrameMetadataWorkerSameOrigin(workerUrl, pageOrigin) {
91
+ if (workerUrl.protocol !== "http:" && workerUrl.protocol !== "https:") return true;
92
+ return workerUrl.origin === pageOrigin;
93
+ }
94
+ function isFrameMetadataRuntimeSupported() {
95
+ if (typeof window === "undefined") return false;
96
+ try {
97
+ if (!isFrameMetadataWorkerSameOrigin(WORKER_URL(), window.location?.origin)) return false;
98
+ } catch {
99
+ return false;
100
+ }
101
+ const maybeWindow = window;
102
+ const userAgent = window.navigator?.userAgent?.toLowerCase() ?? "";
103
+ const isChromiumBased = /(?:chrome|chromium|crmo)\//.test(userAgent) && !/crios\//.test(userAgent);
104
+ const scriptTransformSupported = typeof maybeWindow.RTCRtpScriptTransform !== "undefined" && !isChromiumBased;
105
+ const insertableStreamsSupported = typeof maybeWindow.RTCRtpSender?.prototype?.createEncodedStreams !== "undefined" && typeof maybeWindow.RTCRtpReceiver?.prototype?.createEncodedStreams !== "undefined";
106
+ return scriptTransformSupported || insertableStreamsSupported;
88
107
  }
89
108
  //#endregion
90
- export { FrameMetadataTracker, createBrowserFrameMetadataDiagnostics, createFrameMetadataWorker };
109
+ export { FrameMetadataTracker, createBrowserFrameMetadataDiagnostics, createFrameMetadataWorker, isFrameMetadataRuntimeSupported, isFrameMetadataWorkerSameOrigin };
@@ -1,5 +1,6 @@
1
1
  import { createRealTimeClient } from "../client.js";
2
2
  import { createRealTimeSubscribeClient } from "../subscribe-client.js";
3
+ import { createFrameMetadataWorker, isFrameMetadataRuntimeSupported } from "./frame-metadata-diagnostics.js";
3
4
  import { createPreflight } from "./preflight.js";
4
5
  import { prepareBrowserConnection } from "./prepare-connection.js";
5
6
  //#region src/realtime/browser/index.ts
@@ -16,7 +17,9 @@ const createBrowserRealtime = (options) => {
16
17
  baseUrl: options.subscribeBaseUrl,
17
18
  apiKey: options.apiKey,
18
19
  integration: options.integration,
19
- logger: options.logger
20
+ logger: options.logger,
21
+ createFrameMetadataWorker,
22
+ isFrameMetadataRuntimeSupported
20
23
  });
21
24
  const preflight = createPreflight({
22
25
  logger: options.logger,
@@ -166,7 +166,6 @@ async function runActiveProbe(args) {
166
166
  source = createSyntheticSource(model.width, model.height, resolveFpsNumber(model.fps));
167
167
  const connectTask = connect(source.stream, {
168
168
  model,
169
- debugQuality: true,
170
169
  onRemoteStream: () => {}
171
170
  });
172
171
  signal?.addEventListener("abort", () => connectTask.then((c) => c.disconnect()).catch(() => {}), { once: true });
@@ -1,10 +1,10 @@
1
1
  import { RealtimeObservability } from "../observability/realtime-observability.js";
2
2
  import { isDesktopSafari } from "../../utils/platform.js";
3
3
  import { createLiveKitMediaChannel } from "../media-channel.js";
4
- import { createBrowserFrameMetadataDiagnostics, createFrameMetadataWorker } from "./frame-metadata-diagnostics.js";
4
+ import { createBrowserFrameMetadataDiagnostics, createFrameMetadataWorker, isFrameMetadataRuntimeSupported } from "./frame-metadata-diagnostics.js";
5
5
  import { createMirroredStream, shouldMirrorTrack } from "./mirror-stream.js";
6
6
  //#region src/realtime/browser/prepare-connection.ts
7
- const prepareBrowserConnection = ({ stream, mirror, debugQuality, preferredVideoCodec, fps, logger, observability: observabilityOptions }) => {
7
+ const prepareBrowserConnection = ({ stream, mirror, preferredVideoCodec, fps, logger, observability: observabilityOptions }) => {
8
8
  let inputStream = stream ?? new MediaStream();
9
9
  let disposeMirroring = () => {};
10
10
  if (mirror !== false) try {
@@ -18,11 +18,12 @@ const prepareBrowserConnection = ({ stream, mirror, debugQuality, preferredVideo
18
18
  logger.warn("Failed to mirror input stream; falling back to un-mirrored input", { error: error instanceof Error ? error.message : String(error) });
19
19
  }
20
20
  let pendingWorker;
21
- if (debugQuality) try {
21
+ if (isFrameMetadataRuntimeSupported()) try {
22
22
  pendingWorker = createFrameMetadataWorker();
23
23
  } catch (error) {
24
- logger.warn("Frame-metadata worker unavailable; glass-to-glass latency measurement disabled", { error: error instanceof Error ? error.message : String(error) });
24
+ logger.debug("Frame-metadata worker unavailable; glass-to-glass latency measurement disabled", { error: error instanceof Error ? error.message : String(error) });
25
25
  }
26
+ else logger.debug("Frame-metadata runtime unavailable; glass-to-glass latency measurement disabled");
26
27
  const frameTiming = pendingWorker !== void 0;
27
28
  const takeFrameMetadataWorker = () => {
28
29
  if (pendingWorker) {
@@ -27,11 +27,9 @@ const realTimeClientConnectOptionsSchema = z.object({
27
27
  "vp9"
28
28
  ]).optional(),
29
29
  /**
30
- * Opt-in quality measurement using LiveKit frame metadata. The capture
31
- * timestamp is propagated through inference and matched to output playout to
32
- * surface true glass-to-glass `g2gMs` / `ttffMs` on the `stats` and
33
- * `connectionQuality` signals. Browser-only and experimental because it
34
- * relies on LiveKit's frame-metadata worker and encoded transforms.
30
+ * @deprecated Glass-to-glass measurement now runs automatically in browsers
31
+ * when LiveKit frame metadata is available. This legacy flag is accepted for
32
+ * compatibility and no longer gates measurement.
35
33
  */
36
34
  debugQuality: z.boolean().optional()
37
35
  });
@@ -43,7 +41,6 @@ const createRealTimeClient = (opts) => {
43
41
  if (!parsedOptions.success) throw parsedOptions.error;
44
42
  const { onRemoteStream, onConnectionChange, onConnectionQuality, onQueuePosition, initialState, resolution, preferredVideoCodec } = parsedOptions.data;
45
43
  const mirror = parsedOptions.data.mirror ?? false;
46
- const debugQuality = parsedOptions.data.debugQuality ?? false;
47
44
  let session;
48
45
  let observability;
49
46
  let preparedConnection;
@@ -59,7 +56,6 @@ const createRealTimeClient = (opts) => {
59
56
  preparedConnection = opts.prepareConnection({
60
57
  stream,
61
58
  mirror,
62
- debugQuality,
63
59
  preferredVideoCodec,
64
60
  fps: resolveFpsNumber(options.model.fps),
65
61
  logger,
@@ -19,7 +19,8 @@ const REALTIME_CONFIG = {
19
19
  "invalid session",
20
20
  "401",
21
21
  "invalid api key",
22
- "unauthorized"
22
+ "unauthorized",
23
+ "failed to create livekit frame-metadata worker"
23
24
  ]
24
25
  },
25
26
  methods: {
@@ -46,7 +46,9 @@ var LiveKitMediaChannel = class {
46
46
  if (this.config.createFrameMetadataWorker) try {
47
47
  worker = this.config.createFrameMetadataWorker();
48
48
  } catch (error) {
49
- this.logger.warn("Failed to create LiveKit frame-metadata worker; continuing without latency metrics", { error: error instanceof Error ? error.message : String(error) });
49
+ const detail = error instanceof Error ? error.message : String(error);
50
+ this.logger.warn("Failed to create LiveKit frame-metadata worker", { error: detail });
51
+ throw new Error(`Failed to create LiveKit frame-metadata worker: ${detail}`, { cause: error });
50
52
  }
51
53
  this.frameMetadataEnabled = worker !== void 0;
52
54
  try {
@@ -16,10 +16,10 @@ type ConnectionQualityMetrics = {
16
16
  rttMs: number | null;
17
17
  /**
18
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 frame-metadata measurement is on (`connect({ debugQuality: true })`)
21
- * and past warm-up; null otherwise. When present it drives the latency verdict
22
- * instead of `rttMs`.
19
+ * camera→display latency through the model, excluding startup. Populated in
20
+ * browser sessions when LiveKit frame metadata is available and past warm-up;
21
+ * null otherwise. When present it drives the latency verdict instead of
22
+ * `rttMs`.
23
23
  */
24
24
  g2gMs: number | null;
25
25
  /**
@@ -133,9 +133,8 @@ type WebRTCStats = {
133
133
  };
134
134
  /**
135
135
  * True glass-to-glass latency + end-to-end drop signal, merged in by
136
- * `RealtimeObservability` when the opt-in frame-metadata measurement is active.
137
- * Null otherwise — the stats collector does not
138
- * populate it.
136
+ * `RealtimeObservability` when frame-metadata measurement is active. Null
137
+ * otherwise — the stats collector does not populate it.
139
138
  */
140
139
  glassToGlass: G2GMetrics | null;
141
140
  };
@@ -10,10 +10,9 @@ function assertReactNativeReady() {
10
10
  function unsupportedReactNativeFeature(feature) {
11
11
  throw createUnsupportedPlatformFeatureError(feature, "React Native");
12
12
  }
13
- const prepareReactNativeConnection = ({ stream, mirror, debugQuality, preferredVideoCodec, observability: observabilityOptions }) => {
13
+ const prepareReactNativeConnection = ({ stream, mirror, preferredVideoCodec, observability: observabilityOptions }) => {
14
14
  assertReactNativeReady();
15
15
  if (mirror !== false) unsupportedReactNativeFeature("Outgoing video mirroring");
16
- if (debugQuality) unsupportedReactNativeFeature("debugQuality");
17
16
  return {
18
17
  stream: stream ?? new MediaStream(),
19
18
  observability: new RealtimeObservability(observabilityOptions),
@@ -4,8 +4,11 @@ import { SignalingChannel } from "./signaling-channel.js";
4
4
  import mitt from "mitt";
5
5
  import pRetry, { AbortError } from "p-retry";
6
6
  //#region src/realtime/stream-session.ts
7
- function encodeSubscribeToken(roomName) {
8
- return btoa(JSON.stringify({ room_name: roomName }));
7
+ function encodeSubscribeToken(roomName, options = {}) {
8
+ return btoa(JSON.stringify({
9
+ room_name: roomName,
10
+ ...options.frameTiming ? { frame_timing: true } : {}
11
+ }));
9
12
  }
10
13
  function getInitialImageSizeKb(image) {
11
14
  if (!image) return null;
@@ -137,7 +140,7 @@ var StreamSession = class {
137
140
  this.setState("connected");
138
141
  this.events.emit("sessionStarted", {
139
142
  sessionId: roomInfo.sessionId,
140
- subscribeToken: encodeSubscribeToken(roomInfo.roomName)
143
+ subscribeToken: encodeSubscribeToken(roomInfo.roomName, { frameTiming: this.config.frameTiming })
141
144
  });
142
145
  } catch (error) {
143
146
  this.config.observability?.finishConnectionBreakdown({
@@ -1,15 +1,28 @@
1
- import { classifyWebrtcError } from "../utils/errors.js";
1
+ import { ERROR_CODES, classifyWebrtcError, createSDKError } from "../utils/errors.js";
2
2
  import { createConsoleLogger } from "../utils/logger.js";
3
3
  import { createEventBuffer } from "./event-buffer.js";
4
4
  import { REALTIME_CONFIG } from "./config-realtime.js";
5
5
  import { loadLiveKitClient } from "./livekit.js";
6
6
  import { RealtimeObservability } from "./observability/realtime-observability.js";
7
7
  //#region src/realtime/subscribe-client.ts
8
+ function isDecartSDKError(error) {
9
+ return typeof error === "object" && error !== null && typeof error.code === "string" && typeof error.message === "string";
10
+ }
11
+ function createFrameMetadataSubscribeUnsupportedError(detail) {
12
+ const suffix = detail ? `: ${detail}` : "";
13
+ return createSDKError(ERROR_CODES.UNSUPPORTED_PLATFORM_FEATURE, `This realtime stream requires LiveKit frame metadata, which this SDK environment does not support${suffix}.`, {
14
+ feature: "LiveKit frame metadata subscribe",
15
+ platform: "this SDK environment"
16
+ });
17
+ }
8
18
  function decodeSubscribeToken(token) {
9
19
  try {
10
20
  const payload = JSON.parse(atob(token));
11
21
  if (!payload.room_name || typeof payload.room_name !== "string") throw new Error("Invalid subscribe token format");
12
- return { room_name: payload.room_name };
22
+ return {
23
+ room_name: payload.room_name,
24
+ ...payload.frame_timing === true ? { frame_timing: true } : {}
25
+ };
13
26
  } catch {
14
27
  throw new Error("Invalid subscribe token");
15
28
  }
@@ -50,10 +63,11 @@ const createRealTimeSubscribeClient = (opts) => {
50
63
  const { baseUrl, apiKey, integration } = opts;
51
64
  const logger = opts.logger ?? createConsoleLogger("info");
52
65
  const subscribe = async (options) => {
53
- const { room_name: roomName } = decodeSubscribeToken(options.token);
66
+ const { room_name: roomName, frame_timing: frameTiming } = decodeSubscribeToken(options.token);
54
67
  const { emitter, emitOrBuffer, flush, stop } = createEventBuffer();
55
68
  let observability;
56
69
  let room;
70
+ let frameMetadataWorker;
57
71
  let currentState = "connecting";
58
72
  let remoteStream = null;
59
73
  const setState = (state) => {
@@ -77,7 +91,25 @@ const createRealTimeSubscribeClient = (opts) => {
77
91
  apiKey,
78
92
  roomName
79
93
  });
80
- room = new LiveKitRoom(REALTIME_CONFIG.livekit.roomOptions);
94
+ if (frameTiming) {
95
+ if (!opts.createFrameMetadataWorker) throw createFrameMetadataSubscribeUnsupportedError("this platform has no frame-metadata worker");
96
+ if (!(opts.isFrameMetadataRuntimeSupported?.() ?? false)) throw createFrameMetadataSubscribeUnsupportedError("encoded transforms are unavailable or the SDK is served cross-origin");
97
+ try {
98
+ frameMetadataWorker = opts.createFrameMetadataWorker();
99
+ } catch (error) {
100
+ throw createFrameMetadataSubscribeUnsupportedError(`failed to create the required worker (${error instanceof Error ? error.message : String(error)})`);
101
+ }
102
+ }
103
+ try {
104
+ room = new LiveKitRoom({
105
+ ...REALTIME_CONFIG.livekit.roomOptions,
106
+ ...frameMetadataWorker ? { frameMetadata: { worker: frameMetadataWorker } } : {}
107
+ });
108
+ } catch (error) {
109
+ frameMetadataWorker?.terminate();
110
+ frameMetadataWorker = void 0;
111
+ throw error;
112
+ }
81
113
  const activeRoom = room;
82
114
  activeRoom.on(RoomEvent.TrackSubscribed, (track, _pub, participant) => {
83
115
  if (!participant.identity.startsWith(REALTIME_CONFIG.livekit.inferenceServerIdentityPrefix)) return;
@@ -114,6 +146,11 @@ const createRealTimeSubscribeClient = (opts) => {
114
146
  } catch (error) {
115
147
  observability?.stop();
116
148
  if (room) room.disconnect().catch(() => {});
149
+ frameMetadataWorker?.terminate();
150
+ if (isDecartSDKError(error)) {
151
+ logger.error("Realtime subscribe error", { error: error.message });
152
+ throw error;
153
+ }
117
154
  const err = error instanceof Error ? error : new Error(String(error));
118
155
  logger.error("Realtime subscribe error", { error: err.message });
119
156
  throw classifyWebrtcError(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decartai/sdk",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "Decart's JavaScript SDK",
5
5
  "type": "module",
6
6
  "license": "MIT",