@decartai/sdk 0.1.13 → 0.1.15
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 +109 -10
- package/dist/create-client.d.ts +46 -0
- package/dist/create-client.js +208 -0
- package/dist/files/types.js +4 -1
- package/dist/index.d.ts +8 -150
- package/dist/index.js +6 -116
- package/dist/index.react-native.d.ts +36 -0
- package/dist/index.react-native.js +9 -0
- package/dist/package.js +4 -0
- package/dist/process/types.d.ts +2 -2
- package/dist/realtime/browser/frame-metadata-diagnostics.js +90 -0
- package/dist/realtime/browser/frame-metadata-worker.js +2 -0
- package/dist/realtime/browser/index.js +32 -0
- package/dist/realtime/{mirror-stream.js → browser/mirror-stream.js} +2 -2
- package/dist/realtime/{preflight.js → browser/preflight.js} +20 -115
- package/dist/realtime/browser/prepare-connection.js +57 -0
- package/dist/realtime/client.d.ts +11 -4
- package/dist/realtime/client.js +42 -37
- package/dist/realtime/config-realtime.js +51 -0
- package/dist/realtime/livekit.js +26 -0
- package/dist/realtime/media-channel.js +40 -11
- package/dist/realtime/methods.d.ts +1 -1
- package/dist/realtime/methods.js +7 -2
- package/dist/realtime/observability/connection-quality.d.ts +3 -3
- package/dist/realtime/observability/connection-quality.js +3 -1
- package/dist/realtime/observability/g2g.d.ts +14 -0
- package/dist/realtime/observability/realtime-observability.js +8 -32
- package/dist/realtime/observability/webrtc-stats.d.ts +4 -4
- package/dist/realtime/preflight-connectivity.js +102 -0
- package/dist/realtime/preflight-types.d.ts +30 -0
- package/dist/realtime/react-native/index.js +36 -0
- package/dist/realtime/react-native/preflight.js +10 -0
- package/dist/realtime/react-native/prepare-connection.js +26 -0
- package/dist/realtime/signaling-channel.js +3 -1
- package/dist/realtime/stream-session.js +5 -4
- package/dist/realtime/subscribe-client.js +11 -10
- package/dist/shared/model.d.ts +56 -37
- package/dist/shared/model.js +61 -18
- package/dist/shared/request.js +5 -4
- package/dist/shared/types.d.ts +2 -2
- package/dist/shared/types.js +9 -2
- package/dist/utils/errors.d.ts +3 -0
- package/dist/utils/errors.js +16 -1
- package/dist/utils/media.js +1 -1
- package/dist/utils/platform.js +17 -1
- package/dist/utils/runtime.js +13 -0
- package/dist/utils/user-agent.js +1 -1
- package/dist/version.js +9 -4
- package/package.json +10 -3
- package/dist/realtime/observability/glass-to-glass.d.ts +0 -41
- package/dist/realtime/observability/glass-to-glass.js +0 -229
- package/dist/realtime/observability/pixel-marker.js +0 -144
- package/dist/realtime/preflight.d.ts +0 -59
|
@@ -1,109 +1,8 @@
|
|
|
1
|
-
import { resolveFpsNumber } from "
|
|
2
|
-
import { REALTIME_CONFIG } from "
|
|
3
|
-
import { extractSignals, scoreLowerBetter, worst } from "
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
function candidateType(candidate) {
|
|
7
|
-
if (candidate.type) return candidate.type;
|
|
8
|
-
return /\btyp (\w+)/.exec(candidate.candidate)?.[1] ?? "";
|
|
9
|
-
}
|
|
10
|
-
async function gatherIceCandidates(iceServers, timeoutMs, signal, logger) {
|
|
11
|
-
if (signal?.aborted) return {
|
|
12
|
-
transport: "failed",
|
|
13
|
-
rttMs: null
|
|
14
|
-
};
|
|
15
|
-
const PC = globalThis?.RTCPeerConnection;
|
|
16
|
-
if (typeof PC !== "function") {
|
|
17
|
-
logger.warn("preflight: RTCPeerConnection unavailable in this environment");
|
|
18
|
-
return {
|
|
19
|
-
transport: "failed",
|
|
20
|
-
rttMs: null
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
let pc = null;
|
|
24
|
-
try {
|
|
25
|
-
pc = new PC({ iceServers });
|
|
26
|
-
pc.createDataChannel("decart-preflight");
|
|
27
|
-
let sawSrflx = false;
|
|
28
|
-
let sawOtherCandidate = false;
|
|
29
|
-
let firstSrflxAt = null;
|
|
30
|
-
const start = performance.now();
|
|
31
|
-
await new Promise((resolve) => {
|
|
32
|
-
let settled = false;
|
|
33
|
-
const finish = () => {
|
|
34
|
-
if (settled) return;
|
|
35
|
-
settled = true;
|
|
36
|
-
clearTimeout(timer);
|
|
37
|
-
resolve();
|
|
38
|
-
};
|
|
39
|
-
const timer = setTimeout(finish, timeoutMs);
|
|
40
|
-
signal?.addEventListener("abort", finish, { once: true });
|
|
41
|
-
const peer = pc;
|
|
42
|
-
peer.onicecandidate = (event) => {
|
|
43
|
-
if (!event.candidate || event.candidate.candidate === "") {
|
|
44
|
-
finish();
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
if (candidateType(event.candidate) === "srflx") {
|
|
48
|
-
sawSrflx = true;
|
|
49
|
-
if (firstSrflxAt === null) firstSrflxAt = performance.now();
|
|
50
|
-
} else sawOtherCandidate = true;
|
|
51
|
-
};
|
|
52
|
-
peer.onicegatheringstatechange = () => {
|
|
53
|
-
if (peer.iceGatheringState === "complete") finish();
|
|
54
|
-
};
|
|
55
|
-
peer.createOffer().then((offer) => peer.setLocalDescription(offer)).catch((error) => {
|
|
56
|
-
logger.warn("preflight: failed to create offer", { error: error instanceof Error ? error.message : String(error) });
|
|
57
|
-
finish();
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
const rttMs = firstSrflxAt !== null ? Math.round(firstSrflxAt - start) : null;
|
|
61
|
-
let transport;
|
|
62
|
-
if (sawSrflx) transport = "udp";
|
|
63
|
-
else if (sawOtherCandidate) transport = "relay";
|
|
64
|
-
else transport = "failed";
|
|
65
|
-
return {
|
|
66
|
-
transport,
|
|
67
|
-
rttMs
|
|
68
|
-
};
|
|
69
|
-
} catch (error) {
|
|
70
|
-
logger.warn("preflight: connectivity probe threw", { error: error instanceof Error ? error.message : String(error) });
|
|
71
|
-
return {
|
|
72
|
-
transport: "failed",
|
|
73
|
-
rttMs: null
|
|
74
|
-
};
|
|
75
|
-
} finally {
|
|
76
|
-
try {
|
|
77
|
-
pc?.close();
|
|
78
|
-
} catch {}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
/** Map probe metrics to a connectivity quality verdict. Pure. */
|
|
82
|
-
function classifyConnectivity(metrics, thresholds) {
|
|
83
|
-
const reasons = [];
|
|
84
|
-
let quality;
|
|
85
|
-
if (metrics.transport === "failed") {
|
|
86
|
-
quality = "critical";
|
|
87
|
-
reasons.push("Could not establish any WebRTC connectivity (no ICE candidates gathered). Real-time streaming is unlikely to work on this network.");
|
|
88
|
-
} else if (metrics.transport === "relay") {
|
|
89
|
-
quality = "poor";
|
|
90
|
-
reasons.push("Direct UDP connectivity could not be confirmed; the session will need a TURN relay, which adds latency and can't be verified without starting a session.");
|
|
91
|
-
} else if (metrics.rttMs != null && metrics.rttMs > thresholds.marginalMs) {
|
|
92
|
-
quality = "poor";
|
|
93
|
-
reasons.push(`Network round-trip time is high (~${metrics.rttMs}ms > ${thresholds.marginalMs}ms); the real-time experience may feel laggy.`);
|
|
94
|
-
} else if (metrics.rttMs != null && metrics.rttMs > thresholds.goodMs) {
|
|
95
|
-
quality = "fair";
|
|
96
|
-
reasons.push(`Network round-trip time is elevated (~${metrics.rttMs}ms > ${thresholds.goodMs}ms).`);
|
|
97
|
-
} else quality = "good";
|
|
98
|
-
return {
|
|
99
|
-
quality,
|
|
100
|
-
metrics: {
|
|
101
|
-
transport: metrics.transport,
|
|
102
|
-
rttMs: metrics.rttMs
|
|
103
|
-
},
|
|
104
|
-
reasons
|
|
105
|
-
};
|
|
106
|
-
}
|
|
1
|
+
import { resolveFpsNumber } from "../../shared/model.js";
|
|
2
|
+
import { REALTIME_CONFIG } from "../config-realtime.js";
|
|
3
|
+
import { extractSignals, scoreLowerBetter, worst } from "../observability/connection-quality.js";
|
|
4
|
+
import { classifyConnectivity, gatherIceCandidates } from "../preflight-connectivity.js";
|
|
5
|
+
//#region src/realtime/browser/preflight.ts
|
|
107
6
|
/**
|
|
108
7
|
* Classify an active-probe result. Judges startup (TTFF) and steady-state
|
|
109
8
|
* (mid-stream glass-to-glass) latency separately — both are real experienced
|
|
@@ -131,9 +30,9 @@ function classifyActiveProbe(metrics, thresholds) {
|
|
|
131
30
|
if (q !== "good") reasons.push(`Mid-stream glass-to-glass latency is ~${metrics.g2gMs}ms (good ≤ ${g.goodMs}ms); the real-time experience may feel laggy.`);
|
|
132
31
|
}
|
|
133
32
|
if (metrics.ttffMs == null && metrics.g2gMs == null) if (metrics.rttMs != null) {
|
|
134
|
-
reasons.push("Could not measure glass-to-glass latency during the probe (no
|
|
33
|
+
reasons.push("Could not measure glass-to-glass latency during the probe (no frame metadata); using network RTT instead.");
|
|
135
34
|
dims.push(scoreLowerBetter(metrics.rttMs, thresholds.rtt.goodMs, thresholds.rtt.fairMs, thresholds.rtt.poorMs));
|
|
136
|
-
} else reasons.push("The probe connected but could not measure latency (no
|
|
35
|
+
} else reasons.push("The probe connected but could not measure latency (no frame metadata and no RTT sample).");
|
|
137
36
|
if (metrics.g2gDropRatio != null) {
|
|
138
37
|
const d = thresholds.g2gDrop;
|
|
139
38
|
const q = scoreLowerBetter(metrics.g2gDropRatio, d.good, d.fair, d.poor);
|
|
@@ -182,10 +81,9 @@ function activeMetricsFromStats(stats) {
|
|
|
182
81
|
};
|
|
183
82
|
}
|
|
184
83
|
/**
|
|
185
|
-
* Animated synthetic video source — no camera permission needed
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
* out of where the server reads it (breaking the round trip).
|
|
84
|
+
* Animated synthetic video source — no camera permission needed. It uses the
|
|
85
|
+
* model's input dimensions to exercise the same encode/inference path as a real
|
|
86
|
+
* session without unnecessary server-side resizing.
|
|
189
87
|
*/
|
|
190
88
|
function createSyntheticSource(width, height, fps) {
|
|
191
89
|
if (typeof document === "undefined") throw new Error("deep connectivity probe requires a DOM environment (document is undefined)");
|
|
@@ -232,11 +130,18 @@ const ABORTED_DEEP_PROBE = {
|
|
|
232
130
|
/** Unblock `promise` early when `signal` aborts. */
|
|
233
131
|
function raceAbort(promise, signal) {
|
|
234
132
|
if (!signal) return promise;
|
|
235
|
-
if (signal.aborted) throw signal.reason ??
|
|
133
|
+
if (signal.aborted) throw signal.reason ?? createAbortError();
|
|
236
134
|
return Promise.race([promise, new Promise((_, reject) => {
|
|
237
|
-
signal.addEventListener("abort", () => reject(signal.reason ??
|
|
135
|
+
signal.addEventListener("abort", () => reject(signal.reason ?? createAbortError()), { once: true });
|
|
238
136
|
})]);
|
|
239
137
|
}
|
|
138
|
+
function createAbortError() {
|
|
139
|
+
const DOMExceptionConstructor = globalThis.DOMException;
|
|
140
|
+
if (typeof DOMExceptionConstructor === "function") return new DOMExceptionConstructor("Aborted", "AbortError");
|
|
141
|
+
const error = /* @__PURE__ */ new Error("Aborted");
|
|
142
|
+
error.name = "AbortError";
|
|
143
|
+
return error;
|
|
144
|
+
}
|
|
240
145
|
/** Resolve once enough g2g samples exist or the probe window elapses (never rejects). */
|
|
241
146
|
function waitForProbe(getLatest, minSamples, durationMs, signal) {
|
|
242
147
|
return new Promise((resolve) => {
|
|
@@ -308,4 +213,4 @@ const createPreflight = ({ logger, connect }) => {
|
|
|
308
213
|
return { checkConnectivity };
|
|
309
214
|
};
|
|
310
215
|
//#endregion
|
|
311
|
-
export { createPreflight };
|
|
216
|
+
export { classifyActiveProbe, createPreflight };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { RealtimeObservability } from "../observability/realtime-observability.js";
|
|
2
|
+
import { isDesktopSafari } from "../../utils/platform.js";
|
|
3
|
+
import { createLiveKitMediaChannel } from "../media-channel.js";
|
|
4
|
+
import { createBrowserFrameMetadataDiagnostics, createFrameMetadataWorker } from "./frame-metadata-diagnostics.js";
|
|
5
|
+
import { createMirroredStream, shouldMirrorTrack } from "./mirror-stream.js";
|
|
6
|
+
//#region src/realtime/browser/prepare-connection.ts
|
|
7
|
+
const prepareBrowserConnection = ({ stream, mirror, debugQuality, preferredVideoCodec, fps, logger, observability: observabilityOptions }) => {
|
|
8
|
+
let inputStream = stream ?? new MediaStream();
|
|
9
|
+
let disposeMirroring = () => {};
|
|
10
|
+
if (mirror !== false) try {
|
|
11
|
+
const videoTrack = inputStream.getVideoTracks?.()[0];
|
|
12
|
+
if (videoTrack && (mirror === true || shouldMirrorTrack(videoTrack))) {
|
|
13
|
+
const mirrored = createMirroredStream(inputStream, { fps });
|
|
14
|
+
inputStream = mirrored.stream;
|
|
15
|
+
disposeMirroring = mirrored.dispose;
|
|
16
|
+
} else if (mirror === true && !videoTrack) logger.warn("mirror: true requested but no video track was found on the input stream");
|
|
17
|
+
} catch (error) {
|
|
18
|
+
logger.warn("Failed to mirror input stream; falling back to un-mirrored input", { error: error instanceof Error ? error.message : String(error) });
|
|
19
|
+
}
|
|
20
|
+
let pendingWorker;
|
|
21
|
+
if (debugQuality) try {
|
|
22
|
+
pendingWorker = createFrameMetadataWorker();
|
|
23
|
+
} catch (error) {
|
|
24
|
+
logger.warn("Frame-metadata worker unavailable; glass-to-glass latency measurement disabled", { error: error instanceof Error ? error.message : String(error) });
|
|
25
|
+
}
|
|
26
|
+
const frameTiming = pendingWorker !== void 0;
|
|
27
|
+
const takeFrameMetadataWorker = () => {
|
|
28
|
+
if (pendingWorker) {
|
|
29
|
+
const worker = pendingWorker;
|
|
30
|
+
pendingWorker = void 0;
|
|
31
|
+
return worker;
|
|
32
|
+
}
|
|
33
|
+
return createFrameMetadataWorker();
|
|
34
|
+
};
|
|
35
|
+
const observability = new RealtimeObservability({
|
|
36
|
+
...observabilityOptions,
|
|
37
|
+
glassToGlass: frameTiming ? createBrowserFrameMetadataDiagnostics() : void 0
|
|
38
|
+
});
|
|
39
|
+
const safariCodec = isDesktopSafari() ? "vp8" : void 0;
|
|
40
|
+
return {
|
|
41
|
+
stream: inputStream,
|
|
42
|
+
observability,
|
|
43
|
+
frameTiming,
|
|
44
|
+
videoCodec: safariCodec ?? preferredVideoCodec,
|
|
45
|
+
queryParams: { ...safariCodec ? { livekit_server_codec: safariCodec } : {} },
|
|
46
|
+
createMediaChannel: (config) => createLiveKitMediaChannel({
|
|
47
|
+
...config,
|
|
48
|
+
createFrameMetadataWorker: frameTiming ? takeFrameMetadataWorker : void 0
|
|
49
|
+
}),
|
|
50
|
+
dispose: () => {
|
|
51
|
+
disposeMirroring();
|
|
52
|
+
pendingWorker?.terminate();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
//#endregion
|
|
57
|
+
export { prepareBrowserConnection };
|
|
@@ -14,7 +14,7 @@ declare const realTimeClientInitialStateSchema: z.ZodObject<{
|
|
|
14
14
|
text: z.ZodString;
|
|
15
15
|
enhance: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
16
16
|
}, z.core.$strip>>;
|
|
17
|
-
image: z.ZodOptional<z.ZodUnion<readonly [z.
|
|
17
|
+
image: z.ZodOptional<z.ZodUnion<readonly [z.ZodType<Blob, unknown, z.core.$ZodTypeInternals<Blob, unknown>>, z.ZodType<File, unknown, z.core.$ZodTypeInternals<File, unknown>>, z.ZodString]>>;
|
|
18
18
|
passthrough: z.ZodOptional<z.ZodBoolean>;
|
|
19
19
|
}, z.core.$strip>;
|
|
20
20
|
type OnRemoteStreamFn = (stream: MediaStream) => void;
|
|
@@ -46,7 +46,7 @@ declare const realTimeClientConnectOptionsSchema: z.ZodObject<{
|
|
|
46
46
|
text: z.ZodString;
|
|
47
47
|
enhance: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
48
48
|
}, z.core.$strip>>;
|
|
49
|
-
image: z.ZodOptional<z.ZodUnion<readonly [z.
|
|
49
|
+
image: z.ZodOptional<z.ZodUnion<readonly [z.ZodType<Blob, unknown, z.core.$ZodTypeInternals<Blob, unknown>>, z.ZodType<File, unknown, z.core.$ZodTypeInternals<File, unknown>>, z.ZodString]>>;
|
|
50
50
|
passthrough: z.ZodOptional<z.ZodBoolean>;
|
|
51
51
|
}, z.core.$strip>>;
|
|
52
52
|
queryParams: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
@@ -57,12 +57,19 @@ declare const realTimeClientConnectOptionsSchema: z.ZodObject<{
|
|
|
57
57
|
}>>;
|
|
58
58
|
preferredVideoCodec: z.ZodOptional<z.ZodEnum<{
|
|
59
59
|
h264: "h264";
|
|
60
|
+
vp8: "vp8";
|
|
60
61
|
vp9: "vp9";
|
|
61
62
|
}>>;
|
|
62
63
|
debugQuality: z.ZodOptional<z.ZodBoolean>;
|
|
63
64
|
}, z.core.$strip>;
|
|
64
|
-
type
|
|
65
|
+
type RealtimeMediaStream = {
|
|
66
|
+
getTracks(): unknown[];
|
|
67
|
+
getAudioTracks(): unknown[];
|
|
68
|
+
getVideoTracks(): unknown[];
|
|
69
|
+
};
|
|
70
|
+
type RealTimeClientConnectOptions<TStream extends RealtimeMediaStream = MediaStream> = Omit<z.infer<typeof realTimeClientConnectOptionsSchema>, "model" | "onRemoteStream"> & {
|
|
65
71
|
model: ModelDefinition | CustomModelDefinition;
|
|
72
|
+
onRemoteStream: (stream: TStream) => void;
|
|
66
73
|
};
|
|
67
74
|
type Events = {
|
|
68
75
|
connectionChange: ConnectionState;
|
|
@@ -101,4 +108,4 @@ type RealTimeClient = {
|
|
|
101
108
|
replaceVideoTrack: (track: MediaStreamTrack) => Promise<void>;
|
|
102
109
|
};
|
|
103
110
|
//#endregion
|
|
104
|
-
export { Events, RealTimeClient, RealTimeClientConnectOptions, RealTimeClientInitialState };
|
|
111
|
+
export { Events, RealTimeClient, RealTimeClientConnectOptions, RealTimeClientInitialState, RealtimeMediaStream };
|
package/dist/realtime/client.js
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
import { classifyWebrtcError } from "../utils/errors.js";
|
|
2
|
+
import { createConsoleLogger } from "../utils/logger.js";
|
|
2
3
|
import { isFileRefId } from "../files/types.js";
|
|
3
4
|
import { modelDefinitionSchema, resolveFpsNumber } from "../shared/model.js";
|
|
4
5
|
import { modelStateSchema } from "../shared/types.js";
|
|
5
|
-
import { createConsoleLogger } from "../utils/logger.js";
|
|
6
6
|
import { imageToBase64 } from "../utils/media.js";
|
|
7
|
-
import { isDesktopSafari } from "../utils/platform.js";
|
|
8
7
|
import { createEventBuffer } from "./event-buffer.js";
|
|
9
8
|
import { realtimeMethods } from "./methods.js";
|
|
10
|
-
import { createMirroredStream, shouldMirrorTrack } from "./mirror-stream.js";
|
|
11
|
-
import { RealtimeObservability } from "./observability/realtime-observability.js";
|
|
12
9
|
import { StreamSession } from "./stream-session.js";
|
|
13
10
|
import { z } from "zod";
|
|
14
11
|
//#region src/realtime/client.ts
|
|
@@ -23,7 +20,19 @@ const realTimeClientConnectOptionsSchema = z.object({
|
|
|
23
20
|
queryParams: z.record(z.string(), z.string()).optional(),
|
|
24
21
|
mirror: z.union([z.literal("auto"), z.boolean()]).optional(),
|
|
25
22
|
resolution: z.enum(["720p", "1080p"]).optional(),
|
|
26
|
-
|
|
23
|
+
/** Local track publish codec. Desktop Safari is always pinned to vp8 and ignores this value. */
|
|
24
|
+
preferredVideoCodec: z.enum([
|
|
25
|
+
"h264",
|
|
26
|
+
"vp8",
|
|
27
|
+
"vp9"
|
|
28
|
+
]).optional(),
|
|
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.
|
|
35
|
+
*/
|
|
27
36
|
debugQuality: z.boolean().optional()
|
|
28
37
|
});
|
|
29
38
|
const createRealTimeClient = (opts) => {
|
|
@@ -34,20 +43,10 @@ const createRealTimeClient = (opts) => {
|
|
|
34
43
|
if (!parsedOptions.success) throw parsedOptions.error;
|
|
35
44
|
const { onRemoteStream, onConnectionChange, onConnectionQuality, onQueuePosition, initialState, resolution, preferredVideoCodec } = parsedOptions.data;
|
|
36
45
|
const mirror = parsedOptions.data.mirror ?? false;
|
|
37
|
-
let inputStream = stream ?? new MediaStream();
|
|
38
|
-
let mirroredStream;
|
|
39
|
-
if (mirror !== false) try {
|
|
40
|
-
const firstVideoTrack = inputStream.getVideoTracks?.()[0];
|
|
41
|
-
if (firstVideoTrack && (mirror === true || shouldMirrorTrack(firstVideoTrack))) {
|
|
42
|
-
mirroredStream = createMirroredStream(inputStream, { fps: resolveFpsNumber(options.model.fps) });
|
|
43
|
-
inputStream = mirroredStream.stream;
|
|
44
|
-
} else if (mirror === true && !firstVideoTrack) logger.warn("mirror: true requested but no video track was found on the input stream");
|
|
45
|
-
} catch (error) {
|
|
46
|
-
logger.warn("Failed to mirror input stream; falling back to un-mirrored input", { error: error instanceof Error ? error.message : String(error) });
|
|
47
|
-
}
|
|
48
46
|
const debugQuality = parsedOptions.data.debugQuality ?? false;
|
|
49
47
|
let session;
|
|
50
48
|
let observability;
|
|
49
|
+
let preparedConnection;
|
|
51
50
|
try {
|
|
52
51
|
const initialImageRef = isFileRefId(initialState?.image) ? initialState.image : void 0;
|
|
53
52
|
const initialImage = initialImageRef === void 0 && initialState?.image ? await imageToBase64(initialState.image) : void 0;
|
|
@@ -57,41 +56,47 @@ const createRealTimeClient = (opts) => {
|
|
|
57
56
|
} : void 0;
|
|
58
57
|
const url = `${baseUrl}${options.model.urlPath}`;
|
|
59
58
|
const { emitter: eventEmitter, emitOrBuffer, flush, stop } = createEventBuffer();
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
59
|
+
preparedConnection = opts.prepareConnection({
|
|
60
|
+
stream,
|
|
61
|
+
mirror,
|
|
62
|
+
debugQuality,
|
|
63
|
+
preferredVideoCodec,
|
|
64
|
+
fps: resolveFpsNumber(options.model.fps),
|
|
65
65
|
logger,
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
66
|
+
observability: {
|
|
67
|
+
telemetryEnabled: opts.telemetryEnabled,
|
|
68
|
+
apiKey,
|
|
69
|
+
model: options.model.name,
|
|
70
|
+
integration,
|
|
71
|
+
logger,
|
|
72
|
+
onDiagnostic: (event) => emitOrBuffer("diagnostic", event),
|
|
73
|
+
onStats: (stats) => emitOrBuffer("stats", stats),
|
|
74
|
+
onConnectionQuality: (report) => {
|
|
75
|
+
emitOrBuffer("connectionQuality", report);
|
|
76
|
+
onConnectionQuality?.(report);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
73
79
|
});
|
|
74
|
-
|
|
75
|
-
const safariCodec = isDesktopSafari() ? "vp8" : void 0;
|
|
76
|
-
const publishCodec = safariCodec ?? preferredVideoCodec;
|
|
80
|
+
observability = preparedConnection.observability;
|
|
77
81
|
session = new StreamSession({
|
|
78
82
|
url: `${url}?${new URLSearchParams({
|
|
79
|
-
...
|
|
83
|
+
...preparedConnection.queryParams ?? {},
|
|
80
84
|
...options.queryParams ?? {},
|
|
81
|
-
...debugQuality ? { pixel_latency: "1" } : {},
|
|
82
85
|
api_key: apiKey,
|
|
83
86
|
model: options.model.name,
|
|
84
87
|
...resolution ? { resolution } : {}
|
|
85
88
|
}).toString()}`,
|
|
86
89
|
integration,
|
|
87
90
|
observability,
|
|
88
|
-
|
|
91
|
+
frameTiming: preparedConnection.frameTiming,
|
|
92
|
+
localStream: preparedConnection.stream,
|
|
89
93
|
initialImage,
|
|
90
94
|
initialImageRef,
|
|
91
95
|
initialPrompt,
|
|
92
96
|
initialPassthrough: initialState?.passthrough,
|
|
93
97
|
logger,
|
|
94
|
-
videoCodec:
|
|
98
|
+
videoCodec: preparedConnection.videoCodec,
|
|
99
|
+
createMediaChannel: preparedConnection.createMediaChannel
|
|
95
100
|
});
|
|
96
101
|
let sessionId = null;
|
|
97
102
|
let subscribeToken = null;
|
|
@@ -126,7 +131,7 @@ const createRealTimeClient = (opts) => {
|
|
|
126
131
|
observability?.stop();
|
|
127
132
|
stop();
|
|
128
133
|
activeSession.disconnect();
|
|
129
|
-
|
|
134
|
+
preparedConnection?.dispose();
|
|
130
135
|
},
|
|
131
136
|
on: eventEmitter.on,
|
|
132
137
|
off: eventEmitter.off,
|
|
@@ -158,7 +163,7 @@ const createRealTimeClient = (opts) => {
|
|
|
158
163
|
} catch (error) {
|
|
159
164
|
observability?.stop();
|
|
160
165
|
session?.disconnect();
|
|
161
|
-
|
|
166
|
+
preparedConnection?.dispose();
|
|
162
167
|
throw error;
|
|
163
168
|
}
|
|
164
169
|
};
|
|
@@ -44,43 +44,78 @@ const REALTIME_CONFIG = {
|
|
|
44
44
|
telemetryReportIntervalMs: 1e4,
|
|
45
45
|
telemetryUrl: "https://platform.decart.ai/api/v1/telemetry",
|
|
46
46
|
telemetryMaxItemsPerReport: 120,
|
|
47
|
+
/**
|
|
48
|
+
* Thresholds for the derived in-session connection-quality signal
|
|
49
|
+
* (see observability/connection-quality.ts). Tuned for a camera-up
|
|
50
|
+
* real-time pipeline (~3–3.5 Mbps upstream, model fps ~25–30). All
|
|
51
|
+
* values are tunable here so behaviour can change without code edits.
|
|
52
|
+
*/
|
|
47
53
|
connectionQuality: {
|
|
54
|
+
/** Rolling-window size used to smooth raw per-sample metrics. */
|
|
48
55
|
windowSamples: 5,
|
|
56
|
+
/**
|
|
57
|
+
* Samples to wait before the bitrate dimensions count — the encoder
|
|
58
|
+
* and BWE ramp for several seconds after connect, so early low
|
|
59
|
+
* bitrate is not a slow network. RTT/loss/stall start scoring sooner.
|
|
60
|
+
* `decart-try-on` waits 30 samples watching inbound bitrate alone.
|
|
61
|
+
*/
|
|
49
62
|
warmupSamples: 8,
|
|
63
|
+
/** Consecutive worse samples required before the level downgrades. */
|
|
50
64
|
downgradeConsecutive: 5,
|
|
65
|
+
/** Consecutive better samples required before the level upgrades (recover slow). */
|
|
51
66
|
upgradeConsecutive: 5,
|
|
67
|
+
/** Round-trip time bands (ms). Bands widen by relayExtraMs on TURN-relayed paths. */
|
|
52
68
|
rtt: {
|
|
53
69
|
goodMs: 150,
|
|
54
70
|
fairMs: 300,
|
|
55
71
|
poorMs: 500,
|
|
56
72
|
relayExtraMs: 100
|
|
57
73
|
},
|
|
74
|
+
/**
|
|
75
|
+
* Mid-stream (steady-state) true glass-to-glass latency bands (ms) — the
|
|
76
|
+
* real per-frame camera→display latency through the model, used for the
|
|
77
|
+
* latency dimension *instead of* RTT when frame-metadata measurement is on.
|
|
78
|
+
* Already includes both network legs, so relayExtraMs does not apply.
|
|
79
|
+
* Excludes startup (see `ttff`). Anchored to Datadog
|
|
80
|
+
* `rt.stream.pipeline_latency_ms` (server-side median ~285ms / p95 ~510ms)
|
|
81
|
+
* plus network + jitter-buffer + decode headroom. Tune with real data.
|
|
82
|
+
*/
|
|
58
83
|
glassToGlass: {
|
|
59
84
|
goodMs: 500,
|
|
60
85
|
fairMs: 900,
|
|
61
86
|
poorMs: 1500
|
|
62
87
|
},
|
|
88
|
+
/**
|
|
89
|
+
* Time-to-first-frame bands (ms) — startup latency from connect to the
|
|
90
|
+
* first rendered model frame. An order of magnitude larger than mid-stream
|
|
91
|
+
* and judged separately (a slow first frame is a different problem from a
|
|
92
|
+
* laggy steady state). ~4–5s is an acceptable ("fair") cold start today.
|
|
93
|
+
*/
|
|
63
94
|
ttff: {
|
|
64
95
|
goodMs: 4e3,
|
|
65
96
|
fairMs: 6e3,
|
|
66
97
|
poorMs: 1e4
|
|
67
98
|
},
|
|
99
|
+
/** Fraction of outbound packets the server reports lost (0..1). */
|
|
68
100
|
loss: {
|
|
69
101
|
good: .001,
|
|
70
102
|
fair: .01,
|
|
71
103
|
poor: .05
|
|
72
104
|
},
|
|
105
|
+
/** End-to-end frame drop ratio (0..1), when a frame identity is available. */
|
|
73
106
|
g2gDrop: {
|
|
74
107
|
good: .001,
|
|
75
108
|
fair: .01,
|
|
76
109
|
poor: .05
|
|
77
110
|
},
|
|
111
|
+
/** Upstream headroom = available BWE ÷ the intended publish bitrate (requiredUpstreamKbps). */
|
|
78
112
|
upstream: {
|
|
79
113
|
goodRatio: 1,
|
|
80
114
|
fairRatio: .8,
|
|
81
115
|
poorRatio: .5,
|
|
82
116
|
requiredUpstreamKbps: 3500
|
|
83
117
|
},
|
|
118
|
+
/** Rendered (inbound) frames-per-second. */
|
|
84
119
|
stall: {
|
|
85
120
|
goodFps: 20,
|
|
86
121
|
fairFps: 12,
|
|
@@ -88,13 +123,29 @@ const REALTIME_CONFIG = {
|
|
|
88
123
|
}
|
|
89
124
|
}
|
|
90
125
|
},
|
|
126
|
+
/**
|
|
127
|
+
* SDK-only preflight (see preflight.ts). Validates WebRTC reachability
|
|
128
|
+
* (does UDP egress work / will the path need TURN) and latency via a
|
|
129
|
+
* throwaway RTCPeerConnection — no backend session, no media server.
|
|
130
|
+
*/
|
|
91
131
|
preflight: {
|
|
132
|
+
/** Public STUN servers used to gather server-reflexive candidates. */
|
|
92
133
|
defaultStunUrls: ["stun:stun.l.google.com:19302"],
|
|
134
|
+
/** Abort candidate gathering after this long. */
|
|
93
135
|
iceGatherTimeoutMs: 5e3,
|
|
136
|
+
/** RTT bands (ms) for the preflight verdict. */
|
|
94
137
|
rtt: {
|
|
95
138
|
goodMs: 150,
|
|
96
139
|
marginalMs: 300
|
|
97
140
|
},
|
|
141
|
+
/**
|
|
142
|
+
* Deep probe (`checkConnectivity({ deep: true, model })`): briefly opens a
|
|
143
|
+
* real session with a synthetic source + frame-metadata measurement to get a
|
|
144
|
+
* true glass-to-glass verdict, then tears it down. Costs a short GPU session.
|
|
145
|
+
* The verdict reuses the in-session `connectionQuality` bands. Duration must
|
|
146
|
+
* cover TTFF (~4–5s) + mid-stream warm-up (~2s) before steady-state samples
|
|
147
|
+
* accrue; resolves early once `minSamples` exist.
|
|
148
|
+
*/
|
|
98
149
|
active: {
|
|
99
150
|
durationMs: 12e3,
|
|
100
151
|
minSamples: 5
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createLiveKitInitializationError } from "../utils/errors.js";
|
|
2
|
+
//#region src/realtime/livekit.ts
|
|
3
|
+
let liveKitModulePromise;
|
|
4
|
+
function validateLiveKitModule(module) {
|
|
5
|
+
const invalid = [
|
|
6
|
+
typeof module.Room !== "function" && "Room",
|
|
7
|
+
(typeof module.RoomEvent !== "object" || module.RoomEvent === null) && "RoomEvent",
|
|
8
|
+
(module.Track === null || typeof module.Track !== "object" && typeof module.Track !== "function") && "Track",
|
|
9
|
+
module.Track?.Source?.Camera !== "camera" && "Track.Source.Camera",
|
|
10
|
+
(typeof module.ConnectionState !== "object" || module.ConnectionState === null) && "ConnectionState"
|
|
11
|
+
].filter((name) => name !== false);
|
|
12
|
+
if (invalid.length > 0) throw createLiveKitInitializationError(`livekit-client is missing or has invalid required exports: ${invalid.join(", ")}`);
|
|
13
|
+
return module;
|
|
14
|
+
}
|
|
15
|
+
/** Load the browser-neutral LiveKit client only when realtime is actually used. */
|
|
16
|
+
function loadLiveKitClient() {
|
|
17
|
+
liveKitModulePromise ??= import("livekit-client").then(validateLiveKitModule).catch((error) => {
|
|
18
|
+
liveKitModulePromise = void 0;
|
|
19
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "LIVEKIT_INITIALIZATION_ERROR") throw error;
|
|
20
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
21
|
+
throw createLiveKitInitializationError(`Failed to initialize livekit-client: ${cause.message}`, cause);
|
|
22
|
+
});
|
|
23
|
+
return liveKitModulePromise;
|
|
24
|
+
}
|
|
25
|
+
//#endregion
|
|
26
|
+
export { loadLiveKitClient, validateLiveKitModule };
|
|
@@ -1,24 +1,28 @@
|
|
|
1
1
|
import { createConsoleLogger } from "../utils/logger.js";
|
|
2
2
|
import { REALTIME_CONFIG } from "./config-realtime.js";
|
|
3
|
+
import { loadLiveKitClient } from "./livekit.js";
|
|
3
4
|
import mitt from "mitt";
|
|
4
|
-
import { Room, RoomEvent, Track } from "livekit-client";
|
|
5
5
|
//#region src/realtime/media-channel.ts
|
|
6
|
-
function getDefaultVideoPublishOptions(videoCodec) {
|
|
6
|
+
function getDefaultVideoPublishOptions(source, videoCodec, frameMetadata = false) {
|
|
7
7
|
const resolvedCodec = videoCodec ?? REALTIME_CONFIG.livekit.defaultVideoCodec;
|
|
8
8
|
const maxBitrate = resolvedCodec === "vp9" ? REALTIME_CONFIG.livekit.vp9MaxVideoBitrateBps : REALTIME_CONFIG.livekit.defaultMaxVideoBitrateBps;
|
|
9
9
|
return {
|
|
10
|
-
source
|
|
10
|
+
source,
|
|
11
11
|
videoCodec: resolvedCodec,
|
|
12
12
|
simulcast: resolvedCodec !== "vp9",
|
|
13
13
|
videoEncoding: {
|
|
14
14
|
maxBitrate,
|
|
15
15
|
maxFramerate: REALTIME_CONFIG.livekit.defaultPublishFps
|
|
16
|
-
}
|
|
16
|
+
},
|
|
17
|
+
...frameMetadata ? { frameMetadata: { timestamp: true } } : {}
|
|
17
18
|
};
|
|
18
19
|
}
|
|
19
|
-
var
|
|
20
|
+
var LiveKitMediaChannel = class {
|
|
21
|
+
config;
|
|
20
22
|
room = null;
|
|
23
|
+
cameraTrackSource = null;
|
|
21
24
|
remoteStream = null;
|
|
25
|
+
frameMetadataEnabled = false;
|
|
22
26
|
events = mitt();
|
|
23
27
|
logger;
|
|
24
28
|
constructor(config) {
|
|
@@ -35,14 +39,34 @@ var MediaChannel = class {
|
|
|
35
39
|
this.events.off(event, handler);
|
|
36
40
|
}
|
|
37
41
|
async connect(opts) {
|
|
38
|
-
|
|
42
|
+
const { Room: LiveKitRoom, RoomEvent, Track } = await loadLiveKitClient();
|
|
43
|
+
this.cameraTrackSource = Track.Source.Camera;
|
|
44
|
+
if (!this.room) {
|
|
45
|
+
let worker;
|
|
46
|
+
if (this.config.createFrameMetadataWorker) try {
|
|
47
|
+
worker = this.config.createFrameMetadataWorker();
|
|
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) });
|
|
50
|
+
}
|
|
51
|
+
this.frameMetadataEnabled = worker !== void 0;
|
|
52
|
+
try {
|
|
53
|
+
this.room = new LiveKitRoom({
|
|
54
|
+
...REALTIME_CONFIG.livekit.roomOptions,
|
|
55
|
+
...worker ? { frameMetadata: { worker } } : {}
|
|
56
|
+
});
|
|
57
|
+
} catch (error) {
|
|
58
|
+
worker?.terminate();
|
|
59
|
+
this.frameMetadataEnabled = false;
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
39
63
|
const room = this.room;
|
|
40
64
|
room.on(RoomEvent.TrackSubscribed, (track, _pub, participant) => {
|
|
41
65
|
if (!participant.identity.startsWith(REALTIME_CONFIG.livekit.inferenceServerIdentityPrefix)) return;
|
|
42
|
-
if (track.kind !==
|
|
66
|
+
if (track.kind !== "video" && track.kind !== "audio") return;
|
|
43
67
|
const mediaStreamTrack = track.mediaStreamTrack;
|
|
44
68
|
if (mediaStreamTrack) {
|
|
45
|
-
if (track.kind ===
|
|
69
|
+
if (track.kind === "video") this.config.observability?.attachRemoteVideoTrack(track);
|
|
46
70
|
const tracks = this.remoteStream?.getTracks() ?? [];
|
|
47
71
|
if (!tracks.includes(mediaStreamTrack)) tracks.push(mediaStreamTrack);
|
|
48
72
|
this.remoteStream = new MediaStream(tracks);
|
|
@@ -74,15 +98,20 @@ var MediaChannel = class {
|
|
|
74
98
|
disconnect() {
|
|
75
99
|
const room = this.room;
|
|
76
100
|
this.room = null;
|
|
101
|
+
this.cameraTrackSource = null;
|
|
77
102
|
this.remoteStream = null;
|
|
103
|
+
this.frameMetadataEnabled = false;
|
|
78
104
|
this.config.observability?.setLiveKitRoom(null);
|
|
79
105
|
if (room) room.disconnect().catch(() => {});
|
|
80
106
|
}
|
|
81
107
|
async publishTracks(stream) {
|
|
82
108
|
if (!this.room) return;
|
|
83
|
-
for (const track of stream.getTracks()) if (track.kind === "video")
|
|
84
|
-
|
|
109
|
+
for (const track of stream.getTracks()) if (track.kind === "video") {
|
|
110
|
+
if (!this.cameraTrackSource) throw new Error("Cannot publish video track: media channel is not connected");
|
|
111
|
+
await this.room.localParticipant.publishTrack(track, getDefaultVideoPublishOptions(this.cameraTrackSource, this.config.videoCodec, this.frameMetadataEnabled));
|
|
112
|
+
} else await this.room.localParticipant.publishTrack(track);
|
|
85
113
|
}
|
|
86
114
|
};
|
|
115
|
+
const createLiveKitMediaChannel = (config) => new LiveKitMediaChannel(config);
|
|
87
116
|
//#endregion
|
|
88
|
-
export {
|
|
117
|
+
export { LiveKitMediaChannel, createLiveKitMediaChannel, getDefaultVideoPublishOptions };
|
|
@@ -4,7 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
declare const setInputSchema: z.ZodObject<{
|
|
5
5
|
prompt: z.ZodOptional<z.ZodString>;
|
|
6
6
|
enhance: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
7
|
-
image: z.ZodOptional<z.ZodUnion<readonly [z.
|
|
7
|
+
image: z.ZodOptional<z.ZodUnion<readonly [z.ZodType<Blob, unknown, z.core.$ZodTypeInternals<Blob, unknown>>, z.ZodType<File, unknown, z.core.$ZodTypeInternals<File, unknown>>, z.ZodString, z.ZodNull]>>;
|
|
8
8
|
}, z.core.$strip>;
|
|
9
9
|
type SetInput = z.input<typeof setInputSchema>;
|
|
10
10
|
//#endregion
|