Skip to content

Commit d0972b0

Browse files
authored
Add camera facing mode detection (livekit#738)
1 parent 40f1b48 commit d0972b0

3 files changed

Lines changed: 137 additions & 1 deletion

File tree

.changeset/small-tools-shout.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"livekit-client": patch
3+
---
4+
5+
Add helper function to detect camera `facingMode`.

src/room/track/utils.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { AudioCaptureOptions, VideoCaptureOptions, VideoPresets } from './options';
2-
import { constraintsForOptions, mergeDefaultOptions } from './utils';
2+
import { constraintsForOptions, facingModeFromDeviceLabel, mergeDefaultOptions } from './utils';
33

44
describe('mergeDefaultOptions', () => {
55
const audioDefaults: AudioCaptureOptions = {
@@ -108,3 +108,32 @@ describe('constraintsForOptions', () => {
108108
expect(videoOpts.aspectRatio).toEqual(VideoPresets.h720.resolution.aspectRatio);
109109
});
110110
});
111+
112+
describe('Test facingMode detection', () => {
113+
test('OBS virtual camera should be detected.', () => {
114+
const result = facingModeFromDeviceLabel('OBS Virtual Camera');
115+
expect(result?.facingMode).toEqual('environment');
116+
expect(result?.confidence).toEqual('medium');
117+
});
118+
119+
test.each([
120+
['Peter’s iPhone Camera', { facingMode: 'environment', confidence: 'medium' }],
121+
['iPhone de Théo Camera', { facingMode: 'environment', confidence: 'medium' }],
122+
])(
123+
'Device labels that contain "iphone" should return facingMode "environment".',
124+
(label, expected) => {
125+
const result = facingModeFromDeviceLabel(label);
126+
expect(result?.facingMode).toEqual(expected.facingMode);
127+
expect(result?.confidence).toEqual(expected.confidence);
128+
},
129+
);
130+
131+
test.each([
132+
['Peter’s iPad Camera', { facingMode: 'environment', confidence: 'medium' }],
133+
['iPad de Théo Camera', { facingMode: 'environment', confidence: 'medium' }],
134+
])('Device label that contain "ipad" should detect.', (label, expected) => {
135+
const result = facingModeFromDeviceLabel(label);
136+
expect(result?.facingMode).toEqual(expected.facingMode);
137+
expect(result?.confidence).toEqual(expected.confidence);
138+
});
139+
});

src/room/track/utils.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { sleep } from '../utils';
2+
import log from './../../logger';
3+
import LocalTrack from './LocalTrack';
24
import type { AudioCaptureOptions, CreateLocalTracksOptions, VideoCaptureOptions } from './options';
35
import type { AudioTrack } from './types';
46

@@ -112,3 +114,103 @@ export function getNewAudioContext(): AudioContext | void {
112114
return new AudioContext({ latencyHint: 'interactive' });
113115
}
114116
}
117+
118+
type FacingMode = NonNullable<VideoCaptureOptions['facingMode']>;
119+
type FacingModeFromLocalTrackOptions = {
120+
/**
121+
* If no facing mode can be determined, this value will be used.
122+
* @defaultValue 'user'
123+
*/
124+
defaultFacingMode?: FacingMode;
125+
};
126+
type FacingModeFromLocalTrackReturnValue = {
127+
/**
128+
* The (probable) facingMode of the track.
129+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/facingMode | MDN docs on facingMode}
130+
*/
131+
facingMode: FacingMode;
132+
/**
133+
* The confidence that the returned facingMode is correct.
134+
*/
135+
confidence: 'high' | 'medium' | 'low';
136+
};
137+
138+
/**
139+
* Try to analyze the local track to determine the facing mode of a track.
140+
*
141+
* @remarks
142+
* There is no property supported by all browsers to detect whether a video track originated from a user- or environment-facing camera device.
143+
* For this reason, we use the `facingMode` property when available, but will fall back on a string-based analysis of the device label to determine the facing mode.
144+
* If both methods fail, the default facing mode will be used.
145+
*
146+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/facingMode | MDN docs on facingMode}
147+
* @experimental
148+
*/
149+
export function facingModeFromLocalTrack(
150+
localTrack: LocalTrack | MediaStreamTrack,
151+
options: FacingModeFromLocalTrackOptions = {},
152+
): FacingModeFromLocalTrackReturnValue {
153+
const track = localTrack instanceof LocalTrack ? localTrack.mediaStreamTrack : localTrack;
154+
const trackSettings = track.getSettings();
155+
let result: FacingModeFromLocalTrackReturnValue = {
156+
facingMode: options.defaultFacingMode ?? 'user',
157+
confidence: 'low',
158+
};
159+
160+
// 1. Try to get facingMode from track settings.
161+
if ('facingMode' in trackSettings) {
162+
const rawFacingMode = trackSettings.facingMode;
163+
log.debug('rawFacingMode', { rawFacingMode });
164+
if (rawFacingMode && typeof rawFacingMode === 'string' && isFacingModeValue(rawFacingMode)) {
165+
result = { facingMode: rawFacingMode, confidence: 'high' };
166+
}
167+
}
168+
169+
// 2. If we don't have a high confidence we try to get the facing mode from the device label.
170+
if (['low', 'medium'].includes(result.confidence)) {
171+
log.debug(`Try to get facing mode from device label: (${track.label})`);
172+
const labelAnalysisResult = facingModeFromDeviceLabel(track.label);
173+
if (labelAnalysisResult !== undefined) {
174+
result = labelAnalysisResult;
175+
}
176+
}
177+
178+
return result;
179+
}
180+
181+
const knownDeviceLabels = new Map<string, FacingModeFromLocalTrackReturnValue>([
182+
['obs virtual camera', { facingMode: 'environment', confidence: 'medium' }],
183+
]);
184+
const knownDeviceLabelSections = new Map<string, FacingModeFromLocalTrackReturnValue>([
185+
['iphone', { facingMode: 'environment', confidence: 'medium' }],
186+
['ipad', { facingMode: 'environment', confidence: 'medium' }],
187+
]);
188+
/**
189+
* Attempt to analyze the device label to determine the facing mode.
190+
*
191+
* @experimental
192+
*/
193+
export function facingModeFromDeviceLabel(
194+
deviceLabel: string,
195+
): FacingModeFromLocalTrackReturnValue | undefined {
196+
const label = deviceLabel.trim().toLowerCase();
197+
// Empty string is a valid device label but we can't infer anything from it.
198+
if (label === '') {
199+
return undefined;
200+
}
201+
202+
// Can we match against widely known device labels.
203+
if (knownDeviceLabels.has(label)) {
204+
return knownDeviceLabels.get(label);
205+
}
206+
207+
// Can we match against sections of the device label.
208+
return Array.from(knownDeviceLabelSections.entries()).find(([section]) =>
209+
label.includes(section),
210+
)?.[1];
211+
}
212+
213+
function isFacingModeValue(item: string): item is FacingMode {
214+
const allowedValues: FacingMode[] = ['user', 'environment', 'left', 'right'];
215+
return item === undefined || allowedValues.includes(item as FacingMode);
216+
}

0 commit comments

Comments
 (0)