@memberjunction/ai-realtime-client 6.2.0-edge.2 → 6.2.0-edge.4
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 +156 -29
- package/dist/audio/audioMeter.d.ts +71 -0
- package/dist/audio/audioMeter.d.ts.map +1 -1
- package/dist/audio/audioMeter.js +83 -0
- package/dist/audio/audioMeter.js.map +1 -1
- package/dist/audio/mediaElementAudioRouter.d.ts +41 -0
- package/dist/audio/mediaElementAudioRouter.d.ts.map +1 -0
- package/dist/audio/mediaElementAudioRouter.js +82 -0
- package/dist/audio/mediaElementAudioRouter.js.map +1 -0
- package/dist/audio/micCapture.d.ts +12 -3
- package/dist/audio/micCapture.d.ts.map +1 -1
- package/dist/audio/micCapture.js +15 -1
- package/dist/audio/micCapture.js.map +1 -1
- package/dist/audio/pcmPlayback.d.ts +72 -4
- package/dist/audio/pcmPlayback.d.ts.map +1 -1
- package/dist/audio/pcmPlayback.js +101 -1
- package/dist/audio/pcmPlayback.js.map +1 -1
- package/dist/drivers/assemblyAIRealtimeClient.d.ts +2 -0
- package/dist/drivers/assemblyAIRealtimeClient.d.ts.map +1 -1
- package/dist/drivers/assemblyAIRealtimeClient.js +14 -0
- package/dist/drivers/assemblyAIRealtimeClient.js.map +1 -1
- package/dist/drivers/elevenLabsRealtimeClient.d.ts +2 -0
- package/dist/drivers/elevenLabsRealtimeClient.d.ts.map +1 -1
- package/dist/drivers/elevenLabsRealtimeClient.js +14 -0
- package/dist/drivers/elevenLabsRealtimeClient.js.map +1 -1
- package/dist/drivers/geminiAvatarOutput.d.ts +132 -0
- package/dist/drivers/geminiAvatarOutput.d.ts.map +1 -0
- package/dist/drivers/geminiAvatarOutput.js +310 -0
- package/dist/drivers/geminiAvatarOutput.js.map +1 -0
- package/dist/drivers/geminiEnterpriseRealtimeClient.d.ts +38 -0
- package/dist/drivers/geminiEnterpriseRealtimeClient.d.ts.map +1 -0
- package/dist/drivers/geminiEnterpriseRealtimeClient.js +107 -0
- package/dist/drivers/geminiEnterpriseRealtimeClient.js.map +1 -0
- package/dist/drivers/geminiRealtimeClient.d.ts +314 -48
- package/dist/drivers/geminiRealtimeClient.d.ts.map +1 -1
- package/dist/drivers/geminiRealtimeClient.js +759 -176
- package/dist/drivers/geminiRealtimeClient.js.map +1 -1
- package/dist/drivers/openAILiveClient.d.ts +5 -8
- package/dist/drivers/openAILiveClient.d.ts.map +1 -1
- package/dist/drivers/openAILiveClient.js +19 -32
- package/dist/drivers/openAILiveClient.js.map +1 -1
- package/dist/drivers/openAIRealtimeClient.d.ts +23 -26
- package/dist/drivers/openAIRealtimeClient.d.ts.map +1 -1
- package/dist/drivers/openAIRealtimeClient.js +42 -57
- package/dist/drivers/openAIRealtimeClient.js.map +1 -1
- package/dist/generic/baseRealtimeClient.d.ts +188 -30
- package/dist/generic/baseRealtimeClient.d.ts.map +1 -1
- package/dist/generic/baseRealtimeClient.js +250 -14
- package/dist/generic/baseRealtimeClient.js.map +1 -1
- package/dist/generic/openAIProtocolClient.d.ts +57 -1
- package/dist/generic/openAIProtocolClient.d.ts.map +1 -1
- package/dist/generic/openAIProtocolClient.js +89 -4
- package/dist/generic/openAIProtocolClient.js.map +1 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/media/attachVideoSource.d.ts +23 -0
- package/dist/media/attachVideoSource.d.ts.map +1 -0
- package/dist/media/attachVideoSource.js +49 -0
- package/dist/media/attachVideoSource.js.map +1 -0
- package/dist/media/channelVideoSource.d.ts +110 -14
- package/dist/media/channelVideoSource.d.ts.map +1 -1
- package/dist/media/channelVideoSource.js +183 -18
- package/dist/media/channelVideoSource.js.map +1 -1
- package/dist/media/decoders/frameScheduler.d.ts +119 -0
- package/dist/media/decoders/frameScheduler.d.ts.map +1 -0
- package/dist/media/decoders/frameScheduler.js +238 -0
- package/dist/media/decoders/frameScheduler.js.map +1 -0
- package/dist/media/decoders/imageFrameDecoder.d.ts +58 -0
- package/dist/media/decoders/imageFrameDecoder.d.ts.map +1 -0
- package/dist/media/decoders/imageFrameDecoder.js +110 -0
- package/dist/media/decoders/imageFrameDecoder.js.map +1 -0
- package/dist/media/decoders/mseFmp4Decoder.d.ts +130 -0
- package/dist/media/decoders/mseFmp4Decoder.d.ts.map +1 -0
- package/dist/media/decoders/mseFmp4Decoder.js +402 -0
- package/dist/media/decoders/mseFmp4Decoder.js.map +1 -0
- package/dist/media/decoders/streamCanvas.d.ts +46 -0
- package/dist/media/decoders/streamCanvas.d.ts.map +1 -0
- package/dist/media/decoders/streamCanvas.js +163 -0
- package/dist/media/decoders/streamCanvas.js.map +1 -0
- package/dist/media/decoders/videoMimeTypes.d.ts +29 -0
- package/dist/media/decoders/videoMimeTypes.d.ts.map +1 -0
- package/dist/media/decoders/videoMimeTypes.js +50 -0
- package/dist/media/decoders/videoMimeTypes.js.map +1 -0
- package/dist/media/decoders/webCodecsChunkDecoder.d.ts +99 -0
- package/dist/media/decoders/webCodecsChunkDecoder.d.ts.map +1 -0
- package/dist/media/decoders/webCodecsChunkDecoder.js +265 -0
- package/dist/media/decoders/webCodecsChunkDecoder.js.map +1 -0
- package/dist/media/displayCapture.d.ts +128 -0
- package/dist/media/displayCapture.d.ts.map +1 -0
- package/dist/media/displayCapture.js +245 -0
- package/dist/media/displayCapture.js.map +1 -0
- package/dist/media/frameCapture.d.ts +25 -8
- package/dist/media/frameCapture.d.ts.map +1 -1
- package/dist/media/frameCapture.js +42 -72
- package/dist/media/frameCapture.js.map +1 -1
- package/dist/media/frameSampler.d.ts +94 -0
- package/dist/media/frameSampler.d.ts.map +1 -0
- package/dist/media/frameSampler.js +134 -0
- package/dist/media/frameSampler.js.map +1 -0
- package/dist/media/index.d.ts +32 -0
- package/dist/media/index.d.ts.map +1 -0
- package/dist/media/index.js +32 -0
- package/dist/media/index.js.map +1 -0
- package/dist/media/localMediaController.d.ts +116 -0
- package/dist/media/localMediaController.d.ts.map +1 -0
- package/dist/media/localMediaController.js +281 -0
- package/dist/media/localMediaController.js.map +1 -0
- package/dist/media/mediaLayout.d.ts +93 -0
- package/dist/media/mediaLayout.d.ts.map +1 -0
- package/dist/media/mediaLayout.js +187 -0
- package/dist/media/mediaLayout.js.map +1 -0
- package/dist/media/mediaPreview.d.ts +94 -0
- package/dist/media/mediaPreview.d.ts.map +1 -0
- package/dist/media/mediaPreview.js +154 -0
- package/dist/media/mediaPreview.js.map +1 -0
- package/dist/media/mediaStage.d.ts +99 -0
- package/dist/media/mediaStage.d.ts.map +1 -0
- package/dist/media/mediaStage.js +171 -0
- package/dist/media/mediaStage.js.map +1 -0
- package/dist/media/model.d.ts +154 -0
- package/dist/media/model.d.ts.map +1 -0
- package/dist/media/model.js +9 -0
- package/dist/media/model.js.map +1 -0
- package/dist/media/playbackClock.d.ts +20 -0
- package/dist/media/playbackClock.d.ts.map +1 -0
- package/dist/media/playbackClock.js +13 -0
- package/dist/media/playbackClock.js.map +1 -0
- package/dist/media/videoFrameDecoder.d.ts +86 -0
- package/dist/media/videoFrameDecoder.d.ts.map +1 -0
- package/dist/media/videoFrameDecoder.js +2 -0
- package/dist/media/videoFrameDecoder.js.map +1 -0
- package/dist/media/videoFrameDecoderRegistry.d.ts +40 -0
- package/dist/media/videoFrameDecoderRegistry.d.ts.map +1 -0
- package/dist/media/videoFrameDecoderRegistry.js +69 -0
- package/dist/media/videoFrameDecoderRegistry.js.map +1 -0
- package/dist/media/videoPacing.d.ts +32 -0
- package/dist/media/videoPacing.d.ts.map +1 -0
- package/dist/media/videoPacing.js +37 -0
- package/dist/media/videoPacing.js.map +1 -0
- package/dist/media/videoPlayout.d.ts +168 -0
- package/dist/media/videoPlayout.d.ts.map +1 -0
- package/dist/media/videoPlayout.js +217 -0
- package/dist/media/videoPlayout.js.map +1 -0
- package/dist/media/videoSourceArbiter.d.ts +250 -0
- package/dist/media/videoSourceArbiter.d.ts.map +1 -0
- package/dist/media/videoSourceArbiter.js +423 -0
- package/dist/media/videoSourceArbiter.js.map +1 -0
- package/dist/testing/conformanceAssertions.d.ts +25 -0
- package/dist/testing/conformanceAssertions.d.ts.map +1 -0
- package/dist/testing/conformanceAssertions.js +64 -0
- package/dist/testing/conformanceAssertions.js.map +1 -0
- package/dist/testing/conformanceFixtures.d.ts +76 -0
- package/dist/testing/conformanceFixtures.d.ts.map +1 -0
- package/dist/testing/conformanceFixtures.js +169 -0
- package/dist/testing/conformanceFixtures.js.map +1 -0
- package/dist/testing/conformanceGates.d.ts +32 -0
- package/dist/testing/conformanceGates.d.ts.map +1 -0
- package/dist/testing/conformanceGates.js +35 -0
- package/dist/testing/conformanceGates.js.map +1 -0
- package/dist/testing/conformanceMicrophone.d.ts +10 -0
- package/dist/testing/conformanceMicrophone.d.ts.map +1 -0
- package/dist/testing/conformanceMicrophone.js +71 -0
- package/dist/testing/conformanceMicrophone.js.map +1 -0
- package/dist/testing/conformanceTimeline.d.ts +128 -0
- package/dist/testing/conformanceTimeline.d.ts.map +1 -0
- package/dist/testing/conformanceTimeline.js +32 -0
- package/dist/testing/conformanceTimeline.js.map +1 -0
- package/dist/testing/conformanceVideoStream.d.ts +29 -0
- package/dist/testing/conformanceVideoStream.d.ts.map +1 -0
- package/dist/testing/conformanceVideoStream.js +39 -0
- package/dist/testing/conformanceVideoStream.js.map +1 -0
- package/dist/testing/index.d.ts +29 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +28 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/realtimeVideoConformance.d.ts +23 -0
- package/dist/testing/realtimeVideoConformance.d.ts.map +1 -0
- package/dist/testing/realtimeVideoConformance.js +139 -0
- package/dist/testing/realtimeVideoConformance.js.map +1 -0
- package/dist/testing/realtimeVideoConformanceChecks.d.ts +4 -0
- package/dist/testing/realtimeVideoConformanceChecks.d.ts.map +1 -0
- package/dist/testing/realtimeVideoConformanceChecks.js +164 -0
- package/dist/testing/realtimeVideoConformanceChecks.js.map +1 -0
- package/dist/testing/realtimeVideoConformanceFrameChecks.d.ts +4 -0
- package/dist/testing/realtimeVideoConformanceFrameChecks.d.ts.map +1 -0
- package/dist/testing/realtimeVideoConformanceFrameChecks.js +63 -0
- package/dist/testing/realtimeVideoConformanceFrameChecks.js.map +1 -0
- package/dist/testing/realtimeVideoConformanceSession.d.ts +108 -0
- package/dist/testing/realtimeVideoConformanceSession.d.ts.map +1 -0
- package/dist/testing/realtimeVideoConformanceSession.js +270 -0
- package/dist/testing/realtimeVideoConformanceSession.js.map +1 -0
- package/dist/testing/realtimeVideoConformanceTurnChecks.d.ts +4 -0
- package/dist/testing/realtimeVideoConformanceTurnChecks.d.ts.map +1 -0
- package/dist/testing/realtimeVideoConformanceTurnChecks.js +248 -0
- package/dist/testing/realtimeVideoConformanceTurnChecks.js.map +1 -0
- package/dist/testing/realtimeVideoConformanceTypes.d.ts +155 -0
- package/dist/testing/realtimeVideoConformanceTypes.d.ts.map +1 -0
- package/dist/testing/realtimeVideoConformanceTypes.js +2 -0
- package/dist/testing/realtimeVideoConformanceTypes.js.map +1 -0
- package/dist/testing/recordingPlayout.d.ts +84 -0
- package/dist/testing/recordingPlayout.d.ts.map +1 -0
- package/dist/testing/recordingPlayout.js +120 -0
- package/dist/testing/recordingPlayout.js.map +1 -0
- package/package.json +29 -4
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The VIDEO SOURCE ARBITER: the single writer of a session's inbound video.
|
|
3
|
+
*
|
|
4
|
+
* A realtime session can have many things worth showing the model at once: a shared screen, the camera,
|
|
5
|
+
* a whiteboard, a remote browser. Today's video models accept ONE inbound stream. Before this existed,
|
|
6
|
+
* every source wrote to that one stream directly: whichever happened to send last "won", and the model
|
|
7
|
+
* was never told what it was looking at. The arbiter owns the decision:
|
|
8
|
+
*
|
|
9
|
+
* - **Sources register** (`RegisterSource`) with an id, a label and a kind, and push frames to the
|
|
10
|
+
* arbiter (`PushFrame`) rather than to the client.
|
|
11
|
+
* - **The arbiter maps N live sources onto the model's `MaxInboundVideoStreams`.** With enough streams
|
|
12
|
+
* for every enabled source (a future multi-stream model) frames pass through untouched, each tagged
|
|
13
|
+
* with its source id. Otherwise it ranks the sources by a POLICY (data, overridable) and forwards only
|
|
14
|
+
* the top N.
|
|
15
|
+
* - **The model is told when the choice changes** (`[You can now see: Camera]`), because a model
|
|
16
|
+
* that silently starts seeing something else will describe the old thing.
|
|
17
|
+
* - **The user can switch a source off.** A disabled source is not a candidate, sends nothing, and the
|
|
18
|
+
* model is told it was turned off (`[You can no longer see: Camera (the user turned it off)]`).
|
|
19
|
+
* - **The rate comes only from the negotiated track.** Pacing is read from the sink
|
|
20
|
+
* (`InboundVideoRate`), never a constant here.
|
|
21
|
+
*
|
|
22
|
+
* The notes' wording comes from `@memberjunction/ai` ({@link VideoSourceSeenNote}, {@link VideoSourceTurnedOffNote},
|
|
23
|
+
* {@link VideoSourceTurnedOnNote}), where a meeting's bridge engine gets its notes too, so the model reads one style of
|
|
24
|
+
* note in a call and in a meeting.
|
|
25
|
+
*
|
|
26
|
+
* It deliberately depends only on the narrow {@link IVideoFrameSink} interface (which
|
|
27
|
+
* `BaseRealtimeClient` satisfies) and imports nothing from `generic/` or the drivers, so the media code
|
|
28
|
+
* can be split into its own subpath without untangling it.
|
|
29
|
+
*
|
|
30
|
+
* The default policy and the rationale for its ordering are on {@link DEFAULT_VIDEO_SOURCE_POLICY}.
|
|
31
|
+
*
|
|
32
|
+
* @module @memberjunction/ai-realtime-client
|
|
33
|
+
*/
|
|
34
|
+
import { type RealtimeTrackDirection } from '@memberjunction/ai';
|
|
35
|
+
/**
|
|
36
|
+
* The slice of a realtime client the arbiter writes to. `BaseRealtimeClient` satisfies it structurally,
|
|
37
|
+
* and tests satisfy it with a plain object.
|
|
38
|
+
*/
|
|
39
|
+
export interface IVideoFrameSink {
|
|
40
|
+
/**
|
|
41
|
+
* How many concurrent inbound video streams the model accepts. `0` when none is live (no video track
|
|
42
|
+
* negotiated), `1` for every model shipped so far.
|
|
43
|
+
*/
|
|
44
|
+
readonly MaxInboundVideoStreams: number;
|
|
45
|
+
/** The negotiated inbound video rate in frames per second, or `undefined` when none was negotiated. */
|
|
46
|
+
readonly InboundVideoRate: number | undefined;
|
|
47
|
+
/** Whether a track of the given modality and direction is established and live. */
|
|
48
|
+
IsTrackEstablished(modality: string, direction: RealtimeTrackDirection): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Streams one frame to the model, tagged with the source that produced it. Optional because most
|
|
51
|
+
* drivers have no video; a sink without it makes the arbiter inert.
|
|
52
|
+
*
|
|
53
|
+
* @returns `true` if the frame was sent.
|
|
54
|
+
*/
|
|
55
|
+
SendVideoFrame?(base64Image: string, mimeType?: string, sourceId?: string): boolean;
|
|
56
|
+
/** Injects a context note into the conversation without triggering a reply. */
|
|
57
|
+
SendContextNote(text: string): void;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* What kind of source this is. Open vocabulary: `'camera'` and `'screen'` are CAPTURES (the user chose to
|
|
61
|
+
* share them), `'surface'` is a channel surface (a whiteboard, a remote browser); anything else is treated
|
|
62
|
+
* as a surface unless the policy says otherwise.
|
|
63
|
+
*/
|
|
64
|
+
export type VideoSourceKind = 'camera' | 'screen' | 'surface' | (string & {});
|
|
65
|
+
/** Whether a source kind is a capture (as opposed to a channel surface). */
|
|
66
|
+
export declare function IsVideoCaptureKind(kind: VideoSourceKind | undefined): boolean;
|
|
67
|
+
/** What a source registers itself as. */
|
|
68
|
+
export interface VideoSourceDescriptor {
|
|
69
|
+
/** Stable id of the source within the session. Registering an existing id updates it in place. */
|
|
70
|
+
SourceID: string;
|
|
71
|
+
/** Human-readable name, used in the notes sent to the model and in the UI's "agent can see" list. */
|
|
72
|
+
Label: string;
|
|
73
|
+
/** What kind of source this is. Defaults to `'surface'`. */
|
|
74
|
+
Kind?: VideoSourceKind;
|
|
75
|
+
/**
|
|
76
|
+
* The channel this source belongs to, when it comes from one. The UI maps a source to the user's
|
|
77
|
+
* persisted per-channel visual-perception choice by this key.
|
|
78
|
+
*/
|
|
79
|
+
ChannelKey?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Whether a newly registered source starts enabled. Default `true`. A source the agent may not see yet registers
|
|
82
|
+
* disabled, so the model is never told it is looking at it. Ignored for an id that is already registered; use
|
|
83
|
+
* {@link VideoSourceArbiter.SetSourceEnabled} for that.
|
|
84
|
+
*/
|
|
85
|
+
Enabled?: boolean;
|
|
86
|
+
}
|
|
87
|
+
/** A source as the arbiter currently sees it. */
|
|
88
|
+
export interface VideoSourceState extends VideoSourceDescriptor {
|
|
89
|
+
/** `false` when the user (or exposure policy) turned the source off: it is not a candidate and sends nothing. */
|
|
90
|
+
Enabled: boolean;
|
|
91
|
+
/** Whether the arbiter is currently forwarding this source's frames to the model. */
|
|
92
|
+
Active: boolean;
|
|
93
|
+
/** Frames forwarded to the model so far. */
|
|
94
|
+
FramesSent: number;
|
|
95
|
+
/** Epoch ms of the last forwarded frame; `undefined` before the first. */
|
|
96
|
+
LastFrameAt?: number;
|
|
97
|
+
/**
|
|
98
|
+
* `true` while it is the user's pick ({@link VideoSourceArbiter.SelectSource}), which beats every other rule. Absent
|
|
99
|
+
* otherwise, and while nothing is picked the policy decides.
|
|
100
|
+
*/
|
|
101
|
+
Picked?: boolean;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* One rule of the source-selection policy. Rules apply in order and each contributes the sources it
|
|
105
|
+
* prefers; the arbiter forwards the first `MaxInboundVideoStreams` of the combined ranking.
|
|
106
|
+
*
|
|
107
|
+
* - `'user-pick'`: the source the user explicitly chose ({@link VideoSourceArbiter.SelectSource}).
|
|
108
|
+
* - `'recent-capture'`: captures (camera, screen), the most recently started first.
|
|
109
|
+
* - `'focused'`: the source belonging to the surface the user is looking at
|
|
110
|
+
* ({@link VideoSourceArbiter.SetFocusedSource}).
|
|
111
|
+
* - `'recent'`: any source at all, the most recently started first. The catch-all, so a session whose
|
|
112
|
+
* only source is a surface with no focus still shows it.
|
|
113
|
+
*/
|
|
114
|
+
export type VideoSourcePolicyRule = 'user-pick' | 'recent-capture' | 'focused' | 'recent';
|
|
115
|
+
/**
|
|
116
|
+
* The default selection policy, as DATA so an agent or host can reorder or trim it.
|
|
117
|
+
*
|
|
118
|
+
* Order: an explicit user pick beats everything (the user is the authority on what the agent sees);
|
|
119
|
+
* then the most recently started CAPTURE, because starting a camera or share is a deliberate "look at
|
|
120
|
+
* this" and a surface is always there to come back to; then the focused surface; then simply the newest
|
|
121
|
+
* source, so a lone unfocused surface is never invisible.
|
|
122
|
+
*
|
|
123
|
+
* "Most recently started" is applied to captures rather than to every source. Applied to everything it
|
|
124
|
+
* would always produce a winner and the focused-surface rule could never be reached.
|
|
125
|
+
*/
|
|
126
|
+
export declare const DEFAULT_VIDEO_SOURCE_POLICY: readonly VideoSourcePolicyRule[];
|
|
127
|
+
/** Options for a {@link VideoSourceArbiter}. */
|
|
128
|
+
export interface VideoSourceArbiterOptions {
|
|
129
|
+
/** The selection policy. Defaults to {@link DEFAULT_VIDEO_SOURCE_POLICY}. */
|
|
130
|
+
Policy?: readonly VideoSourcePolicyRule[];
|
|
131
|
+
/**
|
|
132
|
+
* The note sent when the set of sources the model sees changes while sources are being arbitrated.
|
|
133
|
+
* Receives the labels now in view. Return `null` to send nothing. Default: {@link VideoSourceSeenNote}
|
|
134
|
+
* with every label in view.
|
|
135
|
+
*/
|
|
136
|
+
FormatSwitchNote?: (labels: readonly string[]) => string | null;
|
|
137
|
+
/** The note sent when a source is turned off. Return `null` to send nothing. Default: {@link VideoSourceTurnedOffNote}. */
|
|
138
|
+
FormatDisabledNote?: (label: string) => string | null;
|
|
139
|
+
/** The note sent when a source is turned back on. Return `null` to send nothing. Default: {@link VideoSourceTurnedOnNote}. */
|
|
140
|
+
FormatEnabledNote?: (label: string) => string | null;
|
|
141
|
+
/** Reports a problem (a throwing sink). Defaults to `console.error`; the arbiter never throws into a caller's timer. */
|
|
142
|
+
OnError?: (context: string, error: unknown) => void;
|
|
143
|
+
/** Clock, injectable for tests. Defaults to `Date.now`. */
|
|
144
|
+
Now?: () => number;
|
|
145
|
+
}
|
|
146
|
+
/** A sink or a way to find the current one (the client changes across a reconnect). */
|
|
147
|
+
export type VideoFrameSinkSource = IVideoFrameSink | (() => IVideoFrameSink | null | undefined) | null | undefined;
|
|
148
|
+
/**
|
|
149
|
+
* The per-session arbiter. Obtain the one for a client with {@link VideoSourceArbiter.ForSink} so every
|
|
150
|
+
* writer shares it; constructing a second one for the same client would make two writers again.
|
|
151
|
+
*/
|
|
152
|
+
export declare class VideoSourceArbiter {
|
|
153
|
+
private readonly sinkSource;
|
|
154
|
+
private readonly options;
|
|
155
|
+
private static readonly bySink;
|
|
156
|
+
/**
|
|
157
|
+
* The arbiter for a sink, created on first use. One arbiter per sink is the whole point (a single
|
|
158
|
+
* writer), so every bridge and channel that gets its arbiter this way shares it.
|
|
159
|
+
*
|
|
160
|
+
* @param sink The client (anything satisfying {@link IVideoFrameSink}).
|
|
161
|
+
* @param options Options applied only when the arbiter is created.
|
|
162
|
+
*/
|
|
163
|
+
static ForSink(sink: IVideoFrameSink, options?: VideoSourceArbiterOptions): VideoSourceArbiter;
|
|
164
|
+
private readonly sources;
|
|
165
|
+
private readonly policy;
|
|
166
|
+
private readonly handlers;
|
|
167
|
+
private nextSeq;
|
|
168
|
+
private userPick;
|
|
169
|
+
private focused;
|
|
170
|
+
private focusedChannel;
|
|
171
|
+
/** The ids forwarded as of the last reconcile — the baseline a change is detected against. */
|
|
172
|
+
private lastActive;
|
|
173
|
+
/** Whether the previous reconcile had more enabled sources than streams (arbitration in effect). */
|
|
174
|
+
private lastArbitrating;
|
|
175
|
+
constructor(sinkSource: VideoFrameSinkSource, options?: VideoSourceArbiterOptions);
|
|
176
|
+
private get sink();
|
|
177
|
+
/**
|
|
178
|
+
* Registers a source, or updates its label/kind if the id is already registered (its recency, enabled
|
|
179
|
+
* state and counters are kept). A newly registered source counts as the most recently STARTED.
|
|
180
|
+
*/
|
|
181
|
+
RegisterSource(descriptor: VideoSourceDescriptor): void;
|
|
182
|
+
/** Removes a source. Its explicit pick or focus, if it held one, is cleared. */
|
|
183
|
+
UnregisterSource(sourceId: string): void;
|
|
184
|
+
/** Whether a source id is registered. */
|
|
185
|
+
HasSource(sourceId: string): boolean;
|
|
186
|
+
/**
|
|
187
|
+
* The user explicitly picks the source the agent sees (`null` clears the pick). A pick beats every
|
|
188
|
+
* other rule. Returns `false` for an unknown or disabled source (nothing changes).
|
|
189
|
+
*/
|
|
190
|
+
SelectSource(sourceId: string | null): boolean;
|
|
191
|
+
/**
|
|
192
|
+
* Marks the CHANNEL whose surface the user is looking at (`null` clears it): every source registered for that
|
|
193
|
+
* channel counts as focused, including one that registers after this call (a surface the user is already on
|
|
194
|
+
* before its first frame). Used by the `'focused'` rule alongside {@link SetFocusedSource}.
|
|
195
|
+
*/
|
|
196
|
+
SetFocusedChannel(channelKey: string | null): void;
|
|
197
|
+
/** Marks the source whose surface the user is looking at (`null` clears it). Used by the `'focused'` rule. */
|
|
198
|
+
SetFocusedSource(sourceId: string | null): void;
|
|
199
|
+
/**
|
|
200
|
+
* Turns a source on or off. A disabled source is not a candidate and forwards nothing.
|
|
201
|
+
*
|
|
202
|
+
* @param sourceId The source.
|
|
203
|
+
* @param enabled The new state.
|
|
204
|
+
* @param notify Whether to tell the model. Default `true`. A caller that sends its own note about the
|
|
205
|
+
* same change (a channel lowering its exposure) passes `false` so the model is told once.
|
|
206
|
+
* @returns `false` for an unknown source; `true` otherwise (including "already in that state").
|
|
207
|
+
*/
|
|
208
|
+
SetSourceEnabled(sourceId: string, enabled: boolean, notify?: boolean): boolean;
|
|
209
|
+
/** A snapshot of every registered source and whether it is being forwarded, in registration order. */
|
|
210
|
+
GetSources(): VideoSourceState[];
|
|
211
|
+
/** The ids the arbiter would forward right now, best first. */
|
|
212
|
+
GetActiveSourceIDs(): string[];
|
|
213
|
+
/**
|
|
214
|
+
* Subscribes to structural changes (a source registered, removed, enabled, disabled, picked, focused,
|
|
215
|
+
* or the forwarded set changing). Not called per frame.
|
|
216
|
+
*
|
|
217
|
+
* @returns An unsubscribe function.
|
|
218
|
+
*/
|
|
219
|
+
OnChange(handler: () => void): () => void;
|
|
220
|
+
/**
|
|
221
|
+
* A source offers a frame. It is forwarded to the model only if the source is enabled, among the
|
|
222
|
+
* sources the model should see, and not inside the negotiated pacing interval.
|
|
223
|
+
*
|
|
224
|
+
* @param sourceId The producing source (it must be registered).
|
|
225
|
+
* @param base64Image The frame, base64-encoded.
|
|
226
|
+
* @param mimeType Image MIME type. Defaults to `'image/jpeg'`.
|
|
227
|
+
* @returns `true` if the frame was sent to the model.
|
|
228
|
+
*/
|
|
229
|
+
PushFrame(sourceId: string, base64Image: string, mimeType?: string): boolean;
|
|
230
|
+
/** Drops every source and subscription. The arbiter stays usable (it simply has no sources). */
|
|
231
|
+
Dispose(): void;
|
|
232
|
+
/** The enabled sources, ranked by policy, best first. */
|
|
233
|
+
private rank;
|
|
234
|
+
/** The ids to forward: the top N of the ranking, N being the model's inbound video stream count. */
|
|
235
|
+
private computeActive;
|
|
236
|
+
/**
|
|
237
|
+
* Re-derives the forwarded set and, when it changed while sources are being arbitrated, tells the model.
|
|
238
|
+
*
|
|
239
|
+
* @param quiet Update the baseline without sending a switch note (the caller sends its own).
|
|
240
|
+
*/
|
|
241
|
+
private reconcile;
|
|
242
|
+
private formatDisabledNote;
|
|
243
|
+
private formatEnabledNote;
|
|
244
|
+
private now;
|
|
245
|
+
private send;
|
|
246
|
+
private sendNote;
|
|
247
|
+
private emitChange;
|
|
248
|
+
private reportError;
|
|
249
|
+
}
|
|
250
|
+
//# sourceMappingURL=videoSourceArbiter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"videoSourceArbiter.d.ts","sourceRoot":"","sources":["../../src/media/videoSourceArbiter.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The VIDEO SOURCE ARBITER: the single writer of a session's inbound video.
|
|
3
|
+
*
|
|
4
|
+
* A realtime session can have many things worth showing the model at once: a shared screen, the camera,
|
|
5
|
+
* a whiteboard, a remote browser. Today's video models accept ONE inbound stream. Before this existed,
|
|
6
|
+
* every source wrote to that one stream directly: whichever happened to send last "won", and the model
|
|
7
|
+
* was never told what it was looking at. The arbiter owns the decision:
|
|
8
|
+
*
|
|
9
|
+
* - **Sources register** (`RegisterSource`) with an id, a label and a kind, and push frames to the
|
|
10
|
+
* arbiter (`PushFrame`) rather than to the client.
|
|
11
|
+
* - **The arbiter maps N live sources onto the model's `MaxInboundVideoStreams`.** With enough streams
|
|
12
|
+
* for every enabled source (a future multi-stream model) frames pass through untouched, each tagged
|
|
13
|
+
* with its source id. Otherwise it ranks the sources by a POLICY (data, overridable) and forwards only
|
|
14
|
+
* the top N.
|
|
15
|
+
* - **The model is told when the choice changes** (`[You can now see: Camera]`), because a model
|
|
16
|
+
* that silently starts seeing something else will describe the old thing.
|
|
17
|
+
* - **The user can switch a source off.** A disabled source is not a candidate, sends nothing, and the
|
|
18
|
+
* model is told it was turned off (`[You can no longer see: Camera (the user turned it off)]`).
|
|
19
|
+
* - **The rate comes only from the negotiated track.** Pacing is read from the sink
|
|
20
|
+
* (`InboundVideoRate`), never a constant here.
|
|
21
|
+
*
|
|
22
|
+
* The notes' wording comes from `@memberjunction/ai` ({@link VideoSourceSeenNote}, {@link VideoSourceTurnedOffNote},
|
|
23
|
+
* {@link VideoSourceTurnedOnNote}), where a meeting's bridge engine gets its notes too, so the model reads one style of
|
|
24
|
+
* note in a call and in a meeting.
|
|
25
|
+
*
|
|
26
|
+
* It deliberately depends only on the narrow {@link IVideoFrameSink} interface (which
|
|
27
|
+
* `BaseRealtimeClient` satisfies) and imports nothing from `generic/` or the drivers, so the media code
|
|
28
|
+
* can be split into its own subpath without untangling it.
|
|
29
|
+
*
|
|
30
|
+
* The default policy and the rationale for its ordering are on {@link DEFAULT_VIDEO_SOURCE_POLICY}.
|
|
31
|
+
*
|
|
32
|
+
* @module @memberjunction/ai-realtime-client
|
|
33
|
+
*/
|
|
34
|
+
import { VideoSourceSeenNote, VideoSourceTurnedOffNote, VideoSourceTurnedOnNote } from '@memberjunction/ai';
|
|
35
|
+
import { MinVideoFrameSpacingMs } from './videoPacing.js';
|
|
36
|
+
/** Source kinds the default ranking treats as captures: things a person started sharing on purpose. */
|
|
37
|
+
const CAPTURE_KINDS = new Set(['camera', 'screen']);
|
|
38
|
+
/** Whether a source kind is a capture (as opposed to a channel surface). */
|
|
39
|
+
export function IsVideoCaptureKind(kind) {
|
|
40
|
+
return kind !== undefined && CAPTURE_KINDS.has(kind);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The default selection policy, as DATA so an agent or host can reorder or trim it.
|
|
44
|
+
*
|
|
45
|
+
* Order: an explicit user pick beats everything (the user is the authority on what the agent sees);
|
|
46
|
+
* then the most recently started CAPTURE, because starting a camera or share is a deliberate "look at
|
|
47
|
+
* this" and a surface is always there to come back to; then the focused surface; then simply the newest
|
|
48
|
+
* source, so a lone unfocused surface is never invisible.
|
|
49
|
+
*
|
|
50
|
+
* "Most recently started" is applied to captures rather than to every source. Applied to everything it
|
|
51
|
+
* would always produce a winner and the focused-surface rule could never be reached.
|
|
52
|
+
*/
|
|
53
|
+
export const DEFAULT_VIDEO_SOURCE_POLICY = Object.freeze([
|
|
54
|
+
'user-pick',
|
|
55
|
+
'recent-capture',
|
|
56
|
+
'focused',
|
|
57
|
+
'recent',
|
|
58
|
+
]);
|
|
59
|
+
/**
|
|
60
|
+
* How many streams a sink can take right now. A sink that predates `MaxInboundVideoStreams` (a custom
|
|
61
|
+
* client, a test double) is read the way every model was until the field existed: one stream while an
|
|
62
|
+
* inbound video track is live, none otherwise.
|
|
63
|
+
*/
|
|
64
|
+
function streamCapacity(sink) {
|
|
65
|
+
if (!sink) {
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
if (typeof sink.MaxInboundVideoStreams === 'number') {
|
|
69
|
+
return sink.MaxInboundVideoStreams;
|
|
70
|
+
}
|
|
71
|
+
return sink.IsTrackEstablished('video', 'inbound') ? 1 : 0;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The per-session arbiter. Obtain the one for a client with {@link VideoSourceArbiter.ForSink} so every
|
|
75
|
+
* writer shares it; constructing a second one for the same client would make two writers again.
|
|
76
|
+
*/
|
|
77
|
+
export class VideoSourceArbiter {
|
|
78
|
+
static { this.bySink = new WeakMap(); }
|
|
79
|
+
/**
|
|
80
|
+
* The arbiter for a sink, created on first use. One arbiter per sink is the whole point (a single
|
|
81
|
+
* writer), so every bridge and channel that gets its arbiter this way shares it.
|
|
82
|
+
*
|
|
83
|
+
* @param sink The client (anything satisfying {@link IVideoFrameSink}).
|
|
84
|
+
* @param options Options applied only when the arbiter is created.
|
|
85
|
+
*/
|
|
86
|
+
static ForSink(sink, options) {
|
|
87
|
+
let arbiter = VideoSourceArbiter.bySink.get(sink);
|
|
88
|
+
if (!arbiter) {
|
|
89
|
+
arbiter = new VideoSourceArbiter(sink, options);
|
|
90
|
+
VideoSourceArbiter.bySink.set(sink, arbiter);
|
|
91
|
+
}
|
|
92
|
+
return arbiter;
|
|
93
|
+
}
|
|
94
|
+
constructor(sinkSource, options = {}) {
|
|
95
|
+
this.sinkSource = sinkSource;
|
|
96
|
+
this.options = options;
|
|
97
|
+
this.sources = new Map();
|
|
98
|
+
this.handlers = new Set();
|
|
99
|
+
this.nextSeq = 1;
|
|
100
|
+
this.userPick = null;
|
|
101
|
+
this.focused = null;
|
|
102
|
+
this.focusedChannel = null;
|
|
103
|
+
/** The ids forwarded as of the last reconcile — the baseline a change is detected against. */
|
|
104
|
+
this.lastActive = [];
|
|
105
|
+
/** Whether the previous reconcile had more enabled sources than streams (arbitration in effect). */
|
|
106
|
+
this.lastArbitrating = false;
|
|
107
|
+
this.policy = options.Policy ?? DEFAULT_VIDEO_SOURCE_POLICY;
|
|
108
|
+
}
|
|
109
|
+
get sink() {
|
|
110
|
+
const resolved = typeof this.sinkSource === 'function' ? this.sinkSource() : this.sinkSource;
|
|
111
|
+
return resolved ?? null;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Registers a source, or updates its label/kind if the id is already registered (its recency, enabled
|
|
115
|
+
* state and counters are kept). A newly registered source counts as the most recently STARTED.
|
|
116
|
+
*/
|
|
117
|
+
RegisterSource(descriptor) {
|
|
118
|
+
if (!descriptor.SourceID) {
|
|
119
|
+
throw new Error('VideoSourceArbiter.RegisterSource requires a SourceID.');
|
|
120
|
+
}
|
|
121
|
+
const existing = this.sources.get(descriptor.SourceID);
|
|
122
|
+
if (existing) {
|
|
123
|
+
existing.Label = descriptor.Label;
|
|
124
|
+
existing.Kind = descriptor.Kind ?? existing.Kind;
|
|
125
|
+
existing.ChannelKey = descriptor.ChannelKey ?? existing.ChannelKey;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
this.sources.set(descriptor.SourceID, {
|
|
129
|
+
...descriptor,
|
|
130
|
+
Kind: descriptor.Kind ?? 'surface',
|
|
131
|
+
Enabled: descriptor.Enabled ?? true,
|
|
132
|
+
Seq: this.nextSeq++,
|
|
133
|
+
FramesSent: 0,
|
|
134
|
+
LastSentAt: 0,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
this.reconcile();
|
|
138
|
+
this.emitChange();
|
|
139
|
+
}
|
|
140
|
+
/** Removes a source. Its explicit pick or focus, if it held one, is cleared. */
|
|
141
|
+
UnregisterSource(sourceId) {
|
|
142
|
+
if (!this.sources.delete(sourceId)) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (this.userPick === sourceId) {
|
|
146
|
+
this.userPick = null;
|
|
147
|
+
}
|
|
148
|
+
if (this.focused === sourceId) {
|
|
149
|
+
this.focused = null;
|
|
150
|
+
}
|
|
151
|
+
this.reconcile();
|
|
152
|
+
this.emitChange();
|
|
153
|
+
}
|
|
154
|
+
/** Whether a source id is registered. */
|
|
155
|
+
HasSource(sourceId) {
|
|
156
|
+
return this.sources.has(sourceId);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The user explicitly picks the source the agent sees (`null` clears the pick). A pick beats every
|
|
160
|
+
* other rule. Returns `false` for an unknown or disabled source (nothing changes).
|
|
161
|
+
*/
|
|
162
|
+
SelectSource(sourceId) {
|
|
163
|
+
if (sourceId !== null) {
|
|
164
|
+
const source = this.sources.get(sourceId);
|
|
165
|
+
if (!source || !source.Enabled) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
this.userPick = sourceId;
|
|
170
|
+
this.reconcile();
|
|
171
|
+
this.emitChange();
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Marks the CHANNEL whose surface the user is looking at (`null` clears it): every source registered for that
|
|
176
|
+
* channel counts as focused, including one that registers after this call (a surface the user is already on
|
|
177
|
+
* before its first frame). Used by the `'focused'` rule alongside {@link SetFocusedSource}.
|
|
178
|
+
*/
|
|
179
|
+
SetFocusedChannel(channelKey) {
|
|
180
|
+
if (this.focusedChannel === channelKey) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
this.focusedChannel = channelKey;
|
|
184
|
+
this.reconcile();
|
|
185
|
+
this.emitChange();
|
|
186
|
+
}
|
|
187
|
+
/** Marks the source whose surface the user is looking at (`null` clears it). Used by the `'focused'` rule. */
|
|
188
|
+
SetFocusedSource(sourceId) {
|
|
189
|
+
this.focused = sourceId !== null && this.sources.has(sourceId) ? sourceId : null;
|
|
190
|
+
this.reconcile();
|
|
191
|
+
this.emitChange();
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Turns a source on or off. A disabled source is not a candidate and forwards nothing.
|
|
195
|
+
*
|
|
196
|
+
* @param sourceId The source.
|
|
197
|
+
* @param enabled The new state.
|
|
198
|
+
* @param notify Whether to tell the model. Default `true`. A caller that sends its own note about the
|
|
199
|
+
* same change (a channel lowering its exposure) passes `false` so the model is told once.
|
|
200
|
+
* @returns `false` for an unknown source; `true` otherwise (including "already in that state").
|
|
201
|
+
*/
|
|
202
|
+
SetSourceEnabled(sourceId, enabled, notify = true) {
|
|
203
|
+
const source = this.sources.get(sourceId);
|
|
204
|
+
if (!source) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
if (source.Enabled === enabled) {
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
source.Enabled = enabled;
|
|
211
|
+
if (!enabled && this.userPick === sourceId) {
|
|
212
|
+
this.userPick = null;
|
|
213
|
+
}
|
|
214
|
+
// Reconcile FIRST and quietly: the toggle has its own note, so a switch note on top of it
|
|
215
|
+
// would tell the model the same thing twice.
|
|
216
|
+
this.reconcile(true);
|
|
217
|
+
if (notify) {
|
|
218
|
+
this.sendNote(enabled ? this.formatEnabledNote(source.Label) : this.formatDisabledNote(source.Label));
|
|
219
|
+
}
|
|
220
|
+
this.emitChange();
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
/** A snapshot of every registered source and whether it is being forwarded, in registration order. */
|
|
224
|
+
GetSources() {
|
|
225
|
+
const active = new Set(this.computeActive());
|
|
226
|
+
return [...this.sources.values()]
|
|
227
|
+
.sort((a, b) => a.Seq - b.Seq)
|
|
228
|
+
.map((s) => ({
|
|
229
|
+
SourceID: s.SourceID,
|
|
230
|
+
Label: s.Label,
|
|
231
|
+
Kind: s.Kind,
|
|
232
|
+
ChannelKey: s.ChannelKey,
|
|
233
|
+
Enabled: s.Enabled,
|
|
234
|
+
Active: active.has(s.SourceID),
|
|
235
|
+
FramesSent: s.FramesSent,
|
|
236
|
+
LastFrameAt: s.LastFrameAt,
|
|
237
|
+
...(this.userPick === s.SourceID ? { Picked: true } : {}),
|
|
238
|
+
}));
|
|
239
|
+
}
|
|
240
|
+
/** The ids the arbiter would forward right now, best first. */
|
|
241
|
+
GetActiveSourceIDs() {
|
|
242
|
+
return this.computeActive();
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Subscribes to structural changes (a source registered, removed, enabled, disabled, picked, focused,
|
|
246
|
+
* or the forwarded set changing). Not called per frame.
|
|
247
|
+
*
|
|
248
|
+
* @returns An unsubscribe function.
|
|
249
|
+
*/
|
|
250
|
+
OnChange(handler) {
|
|
251
|
+
this.handlers.add(handler);
|
|
252
|
+
return () => {
|
|
253
|
+
this.handlers.delete(handler);
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* A source offers a frame. It is forwarded to the model only if the source is enabled, among the
|
|
258
|
+
* sources the model should see, and not inside the negotiated pacing interval.
|
|
259
|
+
*
|
|
260
|
+
* @param sourceId The producing source (it must be registered).
|
|
261
|
+
* @param base64Image The frame, base64-encoded.
|
|
262
|
+
* @param mimeType Image MIME type. Defaults to `'image/jpeg'`.
|
|
263
|
+
* @returns `true` if the frame was sent to the model.
|
|
264
|
+
*/
|
|
265
|
+
PushFrame(sourceId, base64Image, mimeType = 'image/jpeg') {
|
|
266
|
+
const source = this.sources.get(sourceId);
|
|
267
|
+
const sink = this.sink;
|
|
268
|
+
if (!source || !source.Enabled || !sink || typeof sink.SendVideoFrame !== 'function') {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
if (!sink.IsTrackEstablished('video', 'inbound')) {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
// The stream count can change once the track is negotiated, so the active set is re-derived
|
|
275
|
+
// here rather than trusted from the last registration.
|
|
276
|
+
this.reconcile();
|
|
277
|
+
if (!this.computeActive().includes(sourceId)) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
const now = this.now();
|
|
281
|
+
if (source.LastSentAt > 0 && now - source.LastSentAt < MinVideoFrameSpacingMs(sink.InboundVideoRate)) {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
const sent = this.send(sink, base64Image, mimeType, sourceId);
|
|
285
|
+
if (sent) {
|
|
286
|
+
source.LastSentAt = now;
|
|
287
|
+
source.LastFrameAt = now;
|
|
288
|
+
source.FramesSent++;
|
|
289
|
+
}
|
|
290
|
+
return sent;
|
|
291
|
+
}
|
|
292
|
+
/** Drops every source and subscription. The arbiter stays usable (it simply has no sources). */
|
|
293
|
+
Dispose() {
|
|
294
|
+
this.sources.clear();
|
|
295
|
+
this.userPick = null;
|
|
296
|
+
this.focused = null;
|
|
297
|
+
this.focusedChannel = null;
|
|
298
|
+
this.lastActive = [];
|
|
299
|
+
this.lastArbitrating = false;
|
|
300
|
+
this.handlers.clear();
|
|
301
|
+
}
|
|
302
|
+
// ── selection ───────────────────────────────────────────────────────────
|
|
303
|
+
/** The enabled sources, ranked by policy, best first. */
|
|
304
|
+
rank() {
|
|
305
|
+
const enabled = [...this.sources.values()].filter((s) => s.Enabled);
|
|
306
|
+
const newestFirst = (list) => [...list].sort((a, b) => b.Seq - a.Seq);
|
|
307
|
+
const ranked = [];
|
|
308
|
+
const add = (list) => {
|
|
309
|
+
for (const s of list) {
|
|
310
|
+
if (!ranked.includes(s)) {
|
|
311
|
+
ranked.push(s);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
for (const rule of this.policy) {
|
|
316
|
+
switch (rule) {
|
|
317
|
+
case 'user-pick':
|
|
318
|
+
add(enabled.filter((s) => s.SourceID === this.userPick));
|
|
319
|
+
break;
|
|
320
|
+
case 'recent-capture':
|
|
321
|
+
add(newestFirst(enabled.filter((s) => IsVideoCaptureKind(s.Kind))));
|
|
322
|
+
break;
|
|
323
|
+
case 'focused':
|
|
324
|
+
add(enabled.filter((s) => s.SourceID === this.focused || (this.focusedChannel !== null && s.ChannelKey === this.focusedChannel)));
|
|
325
|
+
break;
|
|
326
|
+
case 'recent':
|
|
327
|
+
add(newestFirst(enabled));
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// A policy without the catch-all must still be total over the enabled sources' ORDER, but it is
|
|
332
|
+
// allowed to leave some out: that is what trimming the policy means.
|
|
333
|
+
return ranked;
|
|
334
|
+
}
|
|
335
|
+
/** The ids to forward: the top N of the ranking, N being the model's inbound video stream count. */
|
|
336
|
+
computeActive() {
|
|
337
|
+
const streams = streamCapacity(this.sink);
|
|
338
|
+
if (streams <= 0) {
|
|
339
|
+
return [];
|
|
340
|
+
}
|
|
341
|
+
return this.rank().slice(0, streams).map((s) => s.SourceID);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Re-derives the forwarded set and, when it changed while sources are being arbitrated, tells the model.
|
|
345
|
+
*
|
|
346
|
+
* @param quiet Update the baseline without sending a switch note (the caller sends its own).
|
|
347
|
+
*/
|
|
348
|
+
reconcile(quiet = false) {
|
|
349
|
+
const sink = this.sink;
|
|
350
|
+
const streams = streamCapacity(sink);
|
|
351
|
+
const enabledCount = [...this.sources.values()].filter((s) => s.Enabled).length;
|
|
352
|
+
const arbitrating = streams > 0 && enabledCount > streams;
|
|
353
|
+
const active = this.computeActive();
|
|
354
|
+
const changed = active.length !== this.lastActive.length || active.some((id, i) => id !== this.lastActive[i]);
|
|
355
|
+
const wasArbitrating = this.lastArbitrating;
|
|
356
|
+
this.lastActive = active;
|
|
357
|
+
this.lastArbitrating = arbitrating;
|
|
358
|
+
if (!changed || quiet || !sink) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
// Notes only matter when the model could be confused about what it sees: more sources than
|
|
362
|
+
// streams now, or just before. A lone source appearing or leaving is not a "switch".
|
|
363
|
+
if (!arbitrating && !wasArbitrating) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (active.length === 0) {
|
|
367
|
+
// Nothing left to switch TO. A source the user turned off has its own note, and a session whose
|
|
368
|
+
// last source stopped has nothing the model could be confused about.
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const labels = active.map((id) => this.sources.get(id)?.Label ?? id);
|
|
372
|
+
this.sendNote(this.options.FormatSwitchNote ? this.options.FormatSwitchNote(labels) : VideoSourceSeenNote(labels));
|
|
373
|
+
}
|
|
374
|
+
// ── plumbing ────────────────────────────────────────────────────────────
|
|
375
|
+
formatDisabledNote(label) {
|
|
376
|
+
return this.options.FormatDisabledNote ? this.options.FormatDisabledNote(label) : VideoSourceTurnedOffNote(label);
|
|
377
|
+
}
|
|
378
|
+
formatEnabledNote(label) {
|
|
379
|
+
return this.options.FormatEnabledNote ? this.options.FormatEnabledNote(label) : VideoSourceTurnedOnNote(label);
|
|
380
|
+
}
|
|
381
|
+
now() {
|
|
382
|
+
return this.options.Now ? this.options.Now() : Date.now();
|
|
383
|
+
}
|
|
384
|
+
send(sink, base64Image, mimeType, sourceId) {
|
|
385
|
+
try {
|
|
386
|
+
return Boolean(sink.SendVideoFrame?.(base64Image, mimeType, sourceId));
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
this.reportError('sending a video frame', error);
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
sendNote(note) {
|
|
394
|
+
const sink = this.sink;
|
|
395
|
+
if (!note || !sink) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
try {
|
|
399
|
+
sink.SendContextNote(note);
|
|
400
|
+
}
|
|
401
|
+
catch (error) {
|
|
402
|
+
this.reportError('sending a source note', error);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
emitChange() {
|
|
406
|
+
for (const handler of [...this.handlers]) {
|
|
407
|
+
try {
|
|
408
|
+
handler();
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
this.reportError('notifying a change handler', error);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
reportError(context, error) {
|
|
416
|
+
if (this.options.OnError) {
|
|
417
|
+
this.options.OnError(context, error);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
console.error(`[VideoSourceArbiter] Error ${context}:`, error);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
//# sourceMappingURL=videoSourceArbiter.js.map
|