@pexip/media 17.2.0 → 17.4.0

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/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ export { createMedia } from './media';
2
+ export { createAudioStreamProcess } from './audioProcessor';
3
+ export { createVideoStreamProcess } from './videoProcessor';
4
+ export * from './types';
5
+ export { setLogger } from './logger';
6
+ export * from './previewController';
7
+ export * from './status';
8
+ export * from './signals';
9
+ export { applyContentHint } from './utils';
10
+ export { createAudioMixingProcess } from './audioMixingProcessor';
11
+ export * from './displayMedia';
@@ -0,0 +1,11 @@
1
+ import type { Logger } from './baseLogger';
2
+ export declare let logger: Readonly<Logger>;
3
+ export declare function setLogger(newLogger: Logger): void;
4
+ /**
5
+ * A logger wrapper to always include some meta base data to the log
6
+ *
7
+ * @param metaBase - The meta data which will always be included into the log
8
+ */
9
+ export declare const createModuleLogger: (metaBase: unknown) => Logger;
10
+ export declare const createLogProxyHandler: <T extends object>(logger: Logger, name: string, scope: string) => ProxyHandler<T>;
11
+ export declare const proxyWithLog: (logger: Logger, scope: string) => <T extends object>(obj: T, name: string) => T;
package/dist/logger.js ADDED
@@ -0,0 +1,44 @@
1
+ import { createConsoleLogger } from './baseLogger';
2
+ export let logger = createConsoleLogger();
3
+ export function setLogger(newLogger) {
4
+ logger = newLogger;
5
+ }
6
+ /**
7
+ * A logger wrapper to always include some meta base data to the log
8
+ *
9
+ * @param metaBase - The meta data which will always be included into the log
10
+ */
11
+ export const createModuleLogger = (metaBase) => {
12
+ return Object.keys(logger).reduce((log, key) => {
13
+ const k = key;
14
+ log[k] = (meta, msg) => {
15
+ if (typeof meta === 'string') {
16
+ return logger[k](metaBase, meta);
17
+ }
18
+ if (typeof meta === 'object') {
19
+ return logger[k]({ ...meta, meta: metaBase }, msg);
20
+ }
21
+ return logger[k]({ meta: metaBase, context: meta }, msg);
22
+ };
23
+ return log;
24
+ }, {});
25
+ };
26
+ export const createLogProxyHandler = (logger, name, scope) => {
27
+ return {
28
+ get: (target, p, receiver) => {
29
+ const value = Reflect.get(target, p, receiver);
30
+ logger.debug({ scope, name, prop: p, value }, `called get ${name}[${String(p)}]`);
31
+ return value;
32
+ },
33
+ // eslint-disable-next-line max-params --- from lib.es2015 and log
34
+ set: (target, p, value, receiver) => {
35
+ logger.debug(
36
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- it's a log call...
37
+ { scope, name, prop: p, value }, `called set ${name}[${String(p)}]`);
38
+ return Reflect.set(target, p, value, receiver);
39
+ },
40
+ };
41
+ };
42
+ export const proxyWithLog = (logger, scope) => (obj, name) => {
43
+ return new Proxy(obj, createLogProxyHandler(logger, name, scope));
44
+ };
@@ -0,0 +1,8 @@
1
+ import type { MediaOptions, MediaController } from './types';
2
+ /**
3
+ * Create an object to interact with the media scream, which is usually used for
4
+ * our main stream.
5
+ *
6
+ * @param options - @see MediaOptions
7
+ */
8
+ export declare const createMedia: ({ getMuteState, signals, mediaProcessors, getDefaultConstraints, }: MediaOptions) => MediaController;
package/dist/media.js ADDED
@@ -0,0 +1,296 @@
1
+ import { subscribe, MediaEventType, MediaDeviceFailure, createStreamTrackEventSubscriptions, getDevices, isRequestedResolution, mergeConstraints, shouldRequestDevice, } from '@pexip/media-control';
2
+ import { createAsyncQueue, isEmpty } from '@pexip/utils';
3
+ import { getPermissionStatus, deriveInitialPermissionStatus, isInitialPermissions, isInitialPermissionsGranted, } from './status';
4
+ import { UserMediaStatus } from './types';
5
+ import { createModuleLogger, logger } from './logger';
6
+ import { createGetUserMediaProcess, requestUserMediaWithRetry, } from './userMedia';
7
+ import { createMediaPipeline, createMediaProcess, buildMedia, getDevicesChanges, shallowCopy, wrapToJSON, hasSettingsChanged, AUDIO_SETTINGS_KEYS, VIDEO_SETTINGS_KEYS, MIXING_SETTINGS_KEYS, applyContentHint, } from './utils';
8
+ import { isMedia } from './typeGuard';
9
+ import { updateFeatureProps as getVideoFeatures } from './videoProcessor';
10
+ import { updateFeatureProps as getAudioFeatures } from './audioProcessor';
11
+ import { updateFeatureProps as getMixingFeatures } from './audioMixingProcessor';
12
+ /**
13
+ * Proxy handler for Media Props
14
+ */
15
+ const createMediaPropsHandler = (signals) => ({
16
+ get: (target, p) => {
17
+ switch (p) {
18
+ default:
19
+ return target[p];
20
+ }
21
+ },
22
+ set: (target, p, value) => {
23
+ if (target[p] === value) {
24
+ return true;
25
+ }
26
+ if (p === 'devices') {
27
+ const nextDevices = value;
28
+ const changes = getDevicesChanges(target[p].flatMap(device => (device.label ? [device] : [])), nextDevices);
29
+ if (isEmpty(changes.found) && isEmpty(changes.lost)) {
30
+ return true;
31
+ }
32
+ }
33
+ logger.debug({
34
+ oldValue: target[p],
35
+ newValue: value,
36
+ meta: {
37
+ module: 'Media',
38
+ props: target,
39
+ },
40
+ }, `Update Props[${p}]`);
41
+ switch (p) {
42
+ case 'devices': {
43
+ if (!Array.isArray(value)) {
44
+ return false;
45
+ }
46
+ const devices = value;
47
+ target[p] = devices;
48
+ signals?.onDevicesChanged?.emit(devices);
49
+ target.media.devices = devices;
50
+ return true;
51
+ }
52
+ case 'media': {
53
+ if (!isMedia(value)) {
54
+ return false;
55
+ }
56
+ const currentStatus = target[p].status;
57
+ target[p] = value;
58
+ signals?.onMediaChanged?.emit(value);
59
+ if (currentStatus !== value.status) {
60
+ signals?.onStatusChanged?.emit(value.status);
61
+ }
62
+ return true;
63
+ }
64
+ default: {
65
+ Reflect.set(target, p, value);
66
+ return true;
67
+ }
68
+ }
69
+ },
70
+ });
71
+ /**
72
+ * Create an object to interact with the media scream, which is usually used for
73
+ * our main stream.
74
+ *
75
+ * @param options - @see MediaOptions
76
+ */
77
+ export const createMedia = ({ getMuteState, signals, mediaProcessors, getDefaultConstraints = () => ({}), }) => {
78
+ const initMedia = (status = UserMediaStatus.Initial, devices = [], constraints) => buildMedia(() => ({ status, devices, constraints }), signals.onStatusChanged?.emit);
79
+ const _props = {
80
+ devices: [],
81
+ media: initMedia(),
82
+ discardMedia: false,
83
+ };
84
+ const props = new Proxy(_props, createMediaPropsHandler(signals));
85
+ const queue = createAsyncQueue();
86
+ const logger = createModuleLogger({
87
+ module: 'Media',
88
+ props: _props,
89
+ get mediaTracks() {
90
+ return _props.media?.stream?.getTracks();
91
+ },
92
+ get rawMediaTracks() {
93
+ return _props.media?.rawStream?.getTracks();
94
+ },
95
+ });
96
+ const cleanup = async () => {
97
+ if (isInitialPermissions(props.media.status)) {
98
+ props.discardMedia = true;
99
+ }
100
+ // carry the most recent status over for the next time
101
+ const status = await deriveInitialPermissionStatus(props.media.status);
102
+ props.media = initMedia(status, props.devices, props.media.constraints);
103
+ };
104
+ // Media Pipeline
105
+ const mediaPipeline = createMediaPipeline([
106
+ createGetUserMediaProcess(requestUserMediaWithRetry(), () => props.devices, { initialMedia: props.media }),
107
+ ...mediaProcessors,
108
+ createMediaProcess(media => {
109
+ // Subscribe the track event from raw stream
110
+ const trackSubscriptions = media.rawStream?.getTracks().map(track => createStreamTrackEventSubscriptions(track, {
111
+ ended: signals.onStreamTrackEnded?.emit,
112
+ mute: signals.onStreamTrackMuted?.emit,
113
+ unmute: signals.onStreamTrackUnmuted?.emit,
114
+ }));
115
+ const muteTrack = (tracks) => (muted) => {
116
+ const [track] = tracks;
117
+ const kind = track?.kind;
118
+ if (track && (kind === 'audio' || kind === 'video')) {
119
+ media[kind === 'audio' ? 'muteAudio' : 'muteVideo'](muted);
120
+ return tracks.forEach(track => {
121
+ logger.debug({ trackInResult: track, intendToMute: muted }, `mute ${track.kind}`);
122
+ signals.onStreamTrackEnabled?.emit(track);
123
+ });
124
+ }
125
+ logger.warn({ tracks, kind }, 'trying to mute but no track');
126
+ };
127
+ const muteAudio = muteTrack(media.stream?.getAudioTracks() ?? []);
128
+ const muteVideo = muteTrack(media.stream?.getVideoTracks() ?? []);
129
+ // Sync mute
130
+ const { audio, video } = getMuteState();
131
+ media.audioMuted !== undefined && muteAudio(audio);
132
+ media.videoMuted !== undefined && muteVideo(video);
133
+ const release = async () => {
134
+ trackSubscriptions?.forEach(unsubscribe => unsubscribe());
135
+ await media.release();
136
+ await cleanup();
137
+ };
138
+ const applyConstraints = async (constraints) => {
139
+ await media.applyConstraints(constraints);
140
+ const videoFeatures = getVideoFeatures(constraints.video, {});
141
+ const audioFeatures = getAudioFeatures(constraints.audio, {});
142
+ media.stream
143
+ ?.getAudioTracks()
144
+ .forEach(applyContentHint(audioFeatures.contentHint));
145
+ media.stream
146
+ ?.getVideoTracks()
147
+ .forEach(applyContentHint(videoFeatures.contentHint));
148
+ };
149
+ logger.debug({ finalAudioMute: audio, finalVideoMute: video }, 'End of media pipeline');
150
+ return wrapToJSON(shallowCopy(media, {
151
+ release,
152
+ muteAudio,
153
+ muteVideo,
154
+ applyConstraints,
155
+ }));
156
+ }),
157
+ ]);
158
+ /**
159
+ * A function to update the media only when necessary, aka the current setup
160
+ * for the stream is different from the new constraints
161
+ *
162
+ * @param constraints - @see MediaDeviceRequest
163
+ */
164
+ const updateMedia = async (constraints) => {
165
+ if (!constraints.audio && !constraints.video) {
166
+ throw new Error(MediaDeviceFailure.MissingConstraintsError);
167
+ }
168
+ const shouldRequestAudio = shouldRequestDevice(constraints.audio, props.media?.rawStream?.getAudioTracks() ?? [], props.devices.filter(device => device.kind === 'audioinput'));
169
+ const shouldRequestVideo = shouldRequestDevice(constraints.video, props.media?.rawStream?.getVideoTracks() ?? [], props.devices.filter(device => device.kind === 'videoinput'));
170
+ const { audio: [prevAudioSettings], video: [prevVideoSettings], } = props.media.getSettings();
171
+ const videoFeatures = getVideoFeatures(constraints.video, {});
172
+ const audioFeatures = getAudioFeatures(constraints.audio, {});
173
+ const mixingFeatures = getMixingFeatures(constraints.audio, {});
174
+ const hasRequestedResolution = ['blur', 'overlay'].includes(videoFeatures.videoSegmentation ?? '') || // Skip when there is a render effect to modify the video
175
+ !props.devices.some(device => device.kind === 'videoinput' && device.label) || // Skip when no authorized video found
176
+ isRequestedResolution(constraints.video, props.media?.videoInput);
177
+ const audioFeaturesChanged = hasSettingsChanged(AUDIO_SETTINGS_KEYS)(prevAudioSettings, audioFeatures);
178
+ const videoFeaturesChanged = hasSettingsChanged(VIDEO_SETTINGS_KEYS)(prevVideoSettings, videoFeatures);
179
+ const mixingFeaturesChanged = hasSettingsChanged(MIXING_SETTINGS_KEYS)(prevAudioSettings, mixingFeatures);
180
+ const ptzFeaturesChanges = hasSettingsChanged(['pan', 'tilt', 'zoom'])(prevVideoSettings, videoFeatures);
181
+ logger.debug({
182
+ constraints,
183
+ stream: props.media.stream,
184
+ currentDevices: props.devices,
185
+ shouldRequestAudio,
186
+ shouldRequestVideo,
187
+ sameResolution: hasRequestedResolution,
188
+ audioFeaturesChanged,
189
+ videoFeaturesChanged,
190
+ mixingFeaturesChanged,
191
+ }, 'Is currently streaming requested stream');
192
+ if (shouldRequestAudio ||
193
+ shouldRequestVideo ||
194
+ ptzFeaturesChanges ||
195
+ !hasRequestedResolution ||
196
+ !props.media.stream) {
197
+ if (props.media) {
198
+ await props.media.release();
199
+ }
200
+ if (props.discardMedia) {
201
+ props.discardMedia = false;
202
+ }
203
+ const media = await mediaPipeline.execute(constraints);
204
+ media.stream
205
+ ?.getAudioTracks()
206
+ .forEach(applyContentHint(audioFeatures.contentHint));
207
+ media.stream
208
+ ?.getVideoTracks()
209
+ .forEach(applyContentHint(videoFeatures.contentHint));
210
+ if (props.discardMedia) {
211
+ logger.debug('Discard media');
212
+ return await media.release();
213
+ }
214
+ props.media = media;
215
+ }
216
+ else if (audioFeaturesChanged ||
217
+ videoFeaturesChanged ||
218
+ mixingFeaturesChanged) {
219
+ props.media.constraints = constraints;
220
+ await props.media.applyConstraints({
221
+ audio: audioFeatures,
222
+ video: videoFeatures,
223
+ });
224
+ }
225
+ };
226
+ const mergeMediaConstraints = (constraints) => {
227
+ const { audio, video } = getDefaultConstraints();
228
+ return {
229
+ audio: audio === false
230
+ ? false
231
+ : mergeConstraints(audio)(constraints.audio),
232
+ video: video === false
233
+ ? false
234
+ : mergeConstraints(video)(constraints.video),
235
+ };
236
+ };
237
+ const getUserMediaAsync = async (constraints) => {
238
+ queue.enqueue(async () => await updateMedia(mergeMediaConstraints(constraints)), false);
239
+ await queue.execute();
240
+ };
241
+ const getUserMedia = constraints => {
242
+ queue.enqueue(async () => await updateMedia(mergeMediaConstraints(constraints)));
243
+ };
244
+ const tryAndGetUserMedia = constraints => {
245
+ queue.enqueue(async () => {
246
+ props.media.status = await getPermissionStatus();
247
+ logger.debug({ status: props.media.status }, 'Current Permission State');
248
+ const mergedConstraints = mergeMediaConstraints(constraints);
249
+ props.media.constraints = mergedConstraints;
250
+ props.devices = await getDevices();
251
+ if (!mergedConstraints.audio && !mergedConstraints.video) {
252
+ return;
253
+ }
254
+ if (isInitialPermissionsGranted(props.media.status)) {
255
+ return await updateMedia(mergedConstraints);
256
+ }
257
+ });
258
+ };
259
+ // Media Control Event handlers
260
+ const handleNoInputs = (_availableDevices) => {
261
+ props.media.status = UserMediaStatus.NoDevicesFound;
262
+ };
263
+ const handleDeviceChange = (devices) => {
264
+ props.devices = devices;
265
+ };
266
+ // Subscribe Media Events for devices
267
+ subscribe(event => {
268
+ switch (event.detail.type) {
269
+ case MediaEventType.NoInputDevices:
270
+ logger.debug(event.detail, 'NoInputDevices emitted');
271
+ handleNoInputs(event.detail.devices);
272
+ break;
273
+ case MediaEventType.DevicesFound:
274
+ case MediaEventType.DevicesLost:
275
+ logger.debug(event.detail, 'DevicesFound/Lost emitted');
276
+ handleDeviceChange([
277
+ ...event.detail.authorizedDevices,
278
+ ...event.detail.unauthorizedDevices,
279
+ ]);
280
+ break;
281
+ default:
282
+ break;
283
+ }
284
+ });
285
+ return {
286
+ get media() {
287
+ return props.media;
288
+ },
289
+ get devices() {
290
+ return props.devices;
291
+ },
292
+ getUserMedia,
293
+ getUserMediaAsync,
294
+ tryAndGetUserMedia,
295
+ };
296
+ };
@@ -0,0 +1,64 @@
1
+ import type { MediaDeviceRequest, MediaDeviceInfoLike } from '@pexip/media-control';
2
+ import type { AsyncQueueOptions } from '@pexip/utils';
3
+ import type { Media, Unsubscribe, MediaSignals, MediaProcessor } from './types';
4
+ type EventCallback<T> = (event: T) => void;
5
+ type EventErrorCallback = (error: Error) => void;
6
+ export type PreviewInput = MediaDeviceInfoLike | undefined;
7
+ export interface PreviewEventHandler {
8
+ audioInput?: EventCallback<PreviewInput>;
9
+ videoInput?: EventCallback<PreviewInput>;
10
+ media?: EventCallback<Media>;
11
+ videoInputError?: EventErrorCallback;
12
+ audioInputError?: EventErrorCallback;
13
+ applyChangesError?: EventErrorCallback;
14
+ revertChangesError?: EventErrorCallback;
15
+ updatingPreview?: EventCallback<boolean>;
16
+ updatingMain?: EventCallback<boolean>;
17
+ unsubscribeMain?: Unsubscribe;
18
+ }
19
+ export interface PreviewStreamParams {
20
+ getCurrentDevices: () => MediaDeviceInfoLike[];
21
+ getCurrentMedia: () => Media | undefined;
22
+ updateMainStream: (request: MediaDeviceRequest) => Promise<void>;
23
+ mediaSignal: MediaSignals['onMediaChanged'];
24
+ onEnded?: () => void;
25
+ fftSize?: number;
26
+ queueOptions?: Partial<AsyncQueueOptions>;
27
+ processors: MediaProcessor[];
28
+ }
29
+ export interface PreviewControllerProps {
30
+ media: Media;
31
+ audioInput?: MediaDeviceInfoLike;
32
+ videoInput?: MediaDeviceInfoLike;
33
+ updatingPreview: boolean;
34
+ updatingMain: boolean;
35
+ originalMainAudioInput?: MediaDeviceInfoLike;
36
+ discardMedia: boolean;
37
+ initialized: boolean;
38
+ }
39
+ export interface PreviewStreamController {
40
+ media: Media;
41
+ audioInputChanged: boolean;
42
+ videoInputChanged: boolean;
43
+ inputChanged: boolean;
44
+ audioInput: PreviewInput;
45
+ videoInput: PreviewInput;
46
+ updatingPreview: boolean;
47
+ updatingMain: boolean;
48
+ updateAudioInput(input: PreviewInput): void;
49
+ updateVideoInput(input: PreviewInput): void;
50
+ applyChanges(force?: boolean): Promise<void>;
51
+ revertChanges(): Promise<void>;
52
+ onMediaChanged(callback: EventCallback<Media>): Unsubscribe;
53
+ onAudioInputChanged(callback: EventCallback<PreviewInput>): Unsubscribe;
54
+ onVideoInputChanged(callback: EventCallback<PreviewInput>): Unsubscribe;
55
+ onUpdatingPreview(callback: EventCallback<boolean>): Unsubscribe;
56
+ onUpdatingMain(callback: EventCallback<boolean>): Unsubscribe;
57
+ onAudioInputError(callback: EventErrorCallback): Unsubscribe;
58
+ onVideoInputError(callback: EventErrorCallback): Unsubscribe;
59
+ onApplyChangesError(callback: EventErrorCallback): Unsubscribe;
60
+ onRevertChangesError(callback: EventErrorCallback): Unsubscribe;
61
+ }
62
+ export declare const createPreviewStreamController: ({ getCurrentDevices, getCurrentMedia, updateMainStream, onEnded, mediaSignal, queueOptions, processors, }: PreviewStreamParams) => PreviewStreamController;
63
+ export type CreatePreviewStreamController = typeof createPreviewStreamController;
64
+ export {};