mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-04 21:35:19 +00:00
Merge pull request #4151 from element-hq/more-more-performance
Performance: Fit video to frame without polling RTP stats
This commit is contained in:
@@ -33,7 +33,6 @@ import { type RemoteUserMediaViewModel } from "./RemoteUserMediaViewModel";
|
|||||||
import { type ObservableScope } from "../ObservableScope";
|
import { type ObservableScope } from "../ObservableScope";
|
||||||
import { showConnectionStats } from "../../settings/settings";
|
import { showConnectionStats } from "../../settings/settings";
|
||||||
import { observeRtpStreamStats$ } from "./observeRtpStreamStats";
|
import { observeRtpStreamStats$ } from "./observeRtpStreamStats";
|
||||||
import { videoFit$, videoSizeFromParticipant$ } from "../../utils/videoFit.ts";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A participant's user media (i.e. their microphone and camera feed).
|
* A participant's user media (i.e. their microphone and camera feed).
|
||||||
@@ -47,7 +46,6 @@ export interface BaseUserMediaViewModel extends BaseMemberMediaViewModel {
|
|||||||
speaking$: Behavior<boolean>;
|
speaking$: Behavior<boolean>;
|
||||||
audioEnabled$: Behavior<boolean>;
|
audioEnabled$: Behavior<boolean>;
|
||||||
videoEnabled$: Behavior<boolean>;
|
videoEnabled$: Behavior<boolean>;
|
||||||
videoFit$: Behavior<"cover" | "contain">;
|
|
||||||
videoOrientation$: Behavior<"landscape" | "portrait">;
|
videoOrientation$: Behavior<"landscape" | "portrait">;
|
||||||
toggleCropVideo: () => void;
|
toggleCropVideo: () => void;
|
||||||
/**
|
/**
|
||||||
@@ -63,12 +61,9 @@ export interface BaseUserMediaViewModel extends BaseMemberMediaViewModel {
|
|||||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||||
>;
|
>;
|
||||||
/**
|
/**
|
||||||
* Set the target dimensions of the HTML element (final dimension after anim).
|
* Set the aspect ratio of the video track to determine the orientation.
|
||||||
* This can be used to determine the best video fit (fit to frame / keep ratio).
|
|
||||||
* @param targetWidth - The target width of the HTML element displaying the video.
|
|
||||||
* @param targetHeight - The target height of the HTML element displaying the video.
|
|
||||||
*/
|
*/
|
||||||
setTargetDimensions: (targetWidth: number, targetHeight: number) => void;
|
setVideoAspectRatio: (ratio: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BaseUserMediaInputs extends Omit<
|
export interface BaseUserMediaInputs extends Omit<
|
||||||
@@ -98,14 +93,8 @@ export function createBaseUserMedia(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const toggleCropVideo$ = new Subject<void>();
|
const toggleCropVideo$ = new Subject<void>();
|
||||||
|
const videoAspectRatio$ = new BehaviorSubject(NaN);
|
||||||
|
|
||||||
// The target size of the video element, used to determine the best video fit.
|
|
||||||
// The target size is the final size of the HTML element after any animations have completed.
|
|
||||||
const targetSize$ = new BehaviorSubject<
|
|
||||||
{ width: number; height: number } | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
const videoSize$ = videoSizeFromParticipant$(participant$);
|
|
||||||
return {
|
return {
|
||||||
...createMemberMedia(scope, {
|
...createMemberMedia(scope, {
|
||||||
...inputs,
|
...inputs,
|
||||||
@@ -132,13 +121,11 @@ export function createBaseUserMedia(
|
|||||||
media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)),
|
media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)),
|
||||||
),
|
),
|
||||||
videoOrientation$: scope.behavior(
|
videoOrientation$: scope.behavior(
|
||||||
videoSize$.pipe(
|
videoAspectRatio$.pipe(
|
||||||
map((s) => (s ? s.width / s.height : 1)),
|
|
||||||
map((aspect) => (aspect > 1 ? "landscape" : "portrait")),
|
map((aspect) => (aspect > 1 ? "landscape" : "portrait")),
|
||||||
),
|
),
|
||||||
"portrait",
|
"portrait",
|
||||||
),
|
),
|
||||||
videoFit$: videoFit$(scope, videoSize$, targetSize$),
|
|
||||||
toggleCropVideo: () => toggleCropVideo$.next(),
|
toggleCropVideo: () => toggleCropVideo$.next(),
|
||||||
rtcBackendIdentity,
|
rtcBackendIdentity,
|
||||||
handRaised$,
|
handRaised$,
|
||||||
@@ -162,8 +149,6 @@ export function createBaseUserMedia(
|
|||||||
return observeRtpStreamStats$(p, Track.Source.Camera, statsType);
|
return observeRtpStreamStats$(p, Track.Source.Camera, statsType);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
setTargetDimensions: (targetWidth: number, targetHeight: number): void => {
|
setVideoAspectRatio: (ratio) => videoAspectRatio$.next(ratio),
|
||||||
targetSize$.next({ width: targetWidth, height: targetHeight });
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,15 @@ import {
|
|||||||
startWith,
|
startWith,
|
||||||
switchMap,
|
switchMap,
|
||||||
map,
|
map,
|
||||||
|
share,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
import { observeTrackReference$ } from "../observeTrackReference";
|
import { observeTrackReference$ } from "../observeTrackReference";
|
||||||
|
|
||||||
|
// Use a shared timer for all the stats observers so that we don't clog up the
|
||||||
|
// event loop with hundreds of timers in large calls
|
||||||
|
const refreshStats$ = interval(1000).pipe(share());
|
||||||
|
|
||||||
export function observeRtpStreamStats$(
|
export function observeRtpStreamStats$(
|
||||||
participant: Participant,
|
participant: Participant,
|
||||||
source: Track.Source,
|
source: Track.Source,
|
||||||
@@ -32,9 +37,7 @@ export function observeRtpStreamStats$(
|
|||||||
> {
|
> {
|
||||||
return combineLatest([
|
return combineLatest([
|
||||||
observeTrackReference$(participant, source),
|
observeTrackReference$(participant, source),
|
||||||
// The update frequency is high because we use this value to update the PiP orientation and the fit/fill video tile props based on that
|
refreshStats$.pipe(startWith(0)),
|
||||||
// We want it to be responsive. For just the debug tools 1s would be sufficient.
|
|
||||||
interval(350).pipe(startWith(0)),
|
|
||||||
]).pipe(
|
]).pipe(
|
||||||
switchMap(async ([trackReference]) => {
|
switchMap(async ([trackReference]) => {
|
||||||
const track = trackReference?.publication?.track;
|
const track = trackReference?.publication?.track;
|
||||||
@@ -69,12 +72,3 @@ export function observeInboundRtpStreamStats$(
|
|||||||
map((x) => x as RTCInboundRtpStreamStats | undefined),
|
map((x) => x as RTCInboundRtpStreamStats | undefined),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function observeOutboundRtpStreamStats$(
|
|
||||||
participant: Participant,
|
|
||||||
source: Track.Source,
|
|
||||||
): Observable<RTCOutboundRtpStreamStats | undefined> {
|
|
||||||
return observeRtpStreamStats$(participant, source, "outbound-rtp").pipe(
|
|
||||||
map((x) => x as RTCOutboundRtpStreamStats | undefined),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
type Ref,
|
type Ref,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -92,7 +91,6 @@ const RingingMediaTile: FC<RingingMediaTileProps> = ({
|
|||||||
}
|
}
|
||||||
avatarStyle="translucent"
|
avatarStyle="translucent"
|
||||||
videoEnabled={false}
|
videoEnabled={false}
|
||||||
videoFit="cover"
|
|
||||||
mirror={false}
|
mirror={false}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -144,19 +142,11 @@ const UserMediaTileInner: FC<UserMediaTileProps & { menu: ReactNode }> = ({
|
|||||||
const audioEnabled = useBehavior(vm.audioEnabled$);
|
const audioEnabled = useBehavior(vm.audioEnabled$);
|
||||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||||
const speaking = useBehavior(vm.speaking$);
|
const speaking = useBehavior(vm.speaking$);
|
||||||
const videoFit = useBehavior(vm.videoFit$);
|
|
||||||
|
|
||||||
const rtcBackendIdentity = vm.rtcBackendIdentity;
|
const rtcBackendIdentity = vm.rtcBackendIdentity;
|
||||||
const handRaised = useBehavior(vm.handRaised$);
|
const handRaised = useBehavior(vm.handRaised$);
|
||||||
const reaction = useBehavior(vm.reaction$);
|
const reaction = useBehavior(vm.reaction$);
|
||||||
|
|
||||||
// Whenever bounds change, inform the viewModel
|
|
||||||
useEffect(() => {
|
|
||||||
if (targetWidth > 0 && targetHeight > 0) {
|
|
||||||
vm.setTargetDimensions(targetWidth, targetHeight);
|
|
||||||
}
|
|
||||||
}, [targetWidth, targetHeight, vm]);
|
|
||||||
|
|
||||||
const AudioIcon = playbackMuted
|
const AudioIcon = playbackMuted
|
||||||
? VolumeOffSolidIcon
|
? VolumeOffSolidIcon
|
||||||
: audioEnabled
|
: audioEnabled
|
||||||
@@ -195,7 +185,6 @@ const UserMediaTileInner: FC<UserMediaTileProps & { menu: ReactNode }> = ({
|
|||||||
userId={vm.userId}
|
userId={vm.userId}
|
||||||
unencryptedWarning={unencryptedWarning}
|
unencryptedWarning={unencryptedWarning}
|
||||||
videoEnabled={videoEnabled}
|
videoEnabled={videoEnabled}
|
||||||
videoFit={videoFit}
|
|
||||||
className={classNames(className, styles.tile, {
|
className={classNames(className, styles.tile, {
|
||||||
[styles.speaking]: showSpeaking,
|
[styles.speaking]: showSpeaking,
|
||||||
[styles.handRaised]: !showSpeaking && handRaised,
|
[styles.handRaised]: !showSpeaking && handRaised,
|
||||||
@@ -231,6 +220,7 @@ const UserMediaTileInner: FC<UserMediaTileProps & { menu: ReactNode }> = ({
|
|||||||
raisedHandOnClick={raisedHandOnClick}
|
raisedHandOnClick={raisedHandOnClick}
|
||||||
waitingForMedia={waitingForMedia}
|
waitingForMedia={waitingForMedia}
|
||||||
focusUrl={focusUrl}
|
focusUrl={focusUrl}
|
||||||
|
setVideoAspectRatio={vm.setVideoAspectRatio}
|
||||||
audioStreamStats={audioStreamStats}
|
audioStreamStats={audioStreamStats}
|
||||||
videoStreamStats={videoStreamStats}
|
videoStreamStats={videoStreamStats}
|
||||||
rtcBackendIdentity={rtcBackendIdentity}
|
rtcBackendIdentity={rtcBackendIdentity}
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ describe("MediaView", () => {
|
|||||||
const baseProps: ComponentProps<typeof MediaView> = {
|
const baseProps: ComponentProps<typeof MediaView> = {
|
||||||
displayName: "some name",
|
displayName: "some name",
|
||||||
videoEnabled: true,
|
videoEnabled: true,
|
||||||
videoFit: "contain",
|
|
||||||
targetWidth: 300,
|
targetWidth: 300,
|
||||||
targetHeight: 200,
|
targetHeight: 200,
|
||||||
mirror: false,
|
mirror: false,
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||||
import { animated } from "@react-spring/web";
|
import { animated } from "@react-spring/web";
|
||||||
import { type FC, type ComponentProps, type ReactNode } from "react";
|
import {
|
||||||
|
type FC,
|
||||||
|
type ComponentProps,
|
||||||
|
type ReactNode,
|
||||||
|
type SyntheticEvent,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { VideoTrack } from "@livekit/components-react";
|
import { VideoTrack } from "@livekit/components-react";
|
||||||
@@ -26,6 +32,7 @@ import { type ReactionOption } from "../reactions";
|
|||||||
import { ReactionIndicator } from "../reactions/ReactionIndicator";
|
import { ReactionIndicator } from "../reactions/ReactionIndicator";
|
||||||
import { RTCConnectionStats } from "../RTCConnectionStats";
|
import { RTCConnectionStats } from "../RTCConnectionStats";
|
||||||
import videoPlaceholder from "../graphics/video-placeholder.gif";
|
import videoPlaceholder from "../graphics/video-placeholder.gif";
|
||||||
|
import { autoVideoFit } from "../utils/videoFit";
|
||||||
|
|
||||||
interface Props extends ComponentProps<typeof animated.div> {
|
interface Props extends ComponentProps<typeof animated.div> {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -33,7 +40,11 @@ interface Props extends ComponentProps<typeof animated.div> {
|
|||||||
targetWidth: number;
|
targetWidth: number;
|
||||||
targetHeight: number;
|
targetHeight: number;
|
||||||
video: TrackReferenceOrPlaceholder | undefined;
|
video: TrackReferenceOrPlaceholder | undefined;
|
||||||
videoFit: "cover" | "contain";
|
/**
|
||||||
|
* How to fit the video content inside the tile. When undefined, MediaView
|
||||||
|
* chooses a smart default based on the aspect ratios of the tile and video.
|
||||||
|
*/
|
||||||
|
videoFit?: "cover" | "contain";
|
||||||
mirror: boolean;
|
mirror: boolean;
|
||||||
soundWaves?: boolean;
|
soundWaves?: boolean;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -55,8 +66,15 @@ interface Props extends ComponentProps<typeof animated.div> {
|
|||||||
audioStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
audioStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
||||||
videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
||||||
rtcBackendIdentity?: string;
|
rtcBackendIdentity?: string;
|
||||||
// The focus url, mainly for debugging purposes
|
/**
|
||||||
|
* The focus url, mainly for debugging purposes.
|
||||||
|
*/
|
||||||
focusUrl?: string;
|
focusUrl?: string;
|
||||||
|
/**
|
||||||
|
* Called whenever the aspect ratio of the video content becomes known or
|
||||||
|
* otherwise changes.
|
||||||
|
*/
|
||||||
|
setVideoAspectRatio?: (ratio: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MediaView: FC<Props> = ({
|
export const MediaView: FC<Props> = ({
|
||||||
@@ -89,6 +107,7 @@ export const MediaView: FC<Props> = ({
|
|||||||
videoStreamStats,
|
videoStreamStats,
|
||||||
rtcBackendIdentity,
|
rtcBackendIdentity,
|
||||||
focusUrl,
|
focusUrl,
|
||||||
|
setVideoAspectRatio: setTheirVideoAspectRatio,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -100,6 +119,22 @@ export const MediaView: FC<Props> = ({
|
|||||||
(soundWaves === undefined ? 0.5 : 0.38),
|
(soundWaves === undefined ? 0.5 : 0.38),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [videoAspectRatio, setOurVideoAspectRatio] = useState<number>(NaN);
|
||||||
|
const tileAspectRatio = targetWidth / targetHeight;
|
||||||
|
|
||||||
|
// Propagate video dimensions
|
||||||
|
const setVideoAspectRatio = (ratio: number) => {
|
||||||
|
setOurVideoAspectRatio(ratio);
|
||||||
|
setTheirVideoAspectRatio?.(ratio);
|
||||||
|
};
|
||||||
|
const videoRef = (el: HTMLVideoElement | null) => {
|
||||||
|
if (el !== null) setVideoAspectRatio(el.videoWidth / el.videoHeight);
|
||||||
|
};
|
||||||
|
const onResize = (ev: SyntheticEvent<HTMLVideoElement>) =>
|
||||||
|
setVideoAspectRatio(
|
||||||
|
ev.currentTarget.videoWidth / ev.currentTarget.videoHeight,
|
||||||
|
);
|
||||||
|
|
||||||
const warnings = unencryptedWarning && (
|
const warnings = unencryptedWarning && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={t("common.unencrypted")}
|
label={t("common.unencrypted")}
|
||||||
@@ -126,7 +161,9 @@ export const MediaView: FC<Props> = ({
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
data-testid="videoTile"
|
data-testid="videoTile"
|
||||||
data-video-enabled={video && videoEnabled}
|
data-video-enabled={video && videoEnabled}
|
||||||
data-video-fit={videoFit}
|
data-video-fit={
|
||||||
|
videoFit ?? autoVideoFit(videoAspectRatio, tileAspectRatio)
|
||||||
|
}
|
||||||
data-background={background}
|
data-background={background}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -158,6 +195,8 @@ export const MediaView: FC<Props> = ({
|
|||||||
// Set the placeholder to a small transparent image. (On Android web
|
// Set the placeholder to a small transparent image. (On Android web
|
||||||
// views the default poster image is particularly ugly.)
|
// views the default poster image is particularly ugly.)
|
||||||
poster={videoPlaceholder}
|
poster={videoPlaceholder}
|
||||||
|
ref={videoRef}
|
||||||
|
onResize={onResize}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ interface SpotlightItemBaseProps {
|
|||||||
background: "solid" | "transparent";
|
background: "solid" | "transparent";
|
||||||
focusable: boolean;
|
focusable: boolean;
|
||||||
"aria-hidden"?: boolean;
|
"aria-hidden"?: boolean;
|
||||||
|
setVideoAspectRatio?: (ratio: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SpotlightMemberMediaItemBaseProps extends SpotlightItemBaseProps {
|
interface SpotlightMemberMediaItemBaseProps extends SpotlightItemBaseProps {
|
||||||
@@ -77,7 +78,6 @@ interface SpotlightMemberMediaItemBaseProps extends SpotlightItemBaseProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SpotlightUserMediaItemBaseProps extends SpotlightMemberMediaItemBaseProps {
|
interface SpotlightUserMediaItemBaseProps extends SpotlightMemberMediaItemBaseProps {
|
||||||
videoFit: "contain" | "cover";
|
|
||||||
videoEnabled: boolean;
|
videoEnabled: boolean;
|
||||||
soundWaves: boolean | undefined;
|
soundWaves: boolean | undefined;
|
||||||
}
|
}
|
||||||
@@ -120,20 +120,12 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
|
|||||||
targetHeight,
|
targetHeight,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const videoFit = useBehavior(vm.videoFit$);
|
|
||||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||||
const speaking = useBehavior(vm.speaking$);
|
const speaking = useBehavior(vm.speaking$);
|
||||||
|
|
||||||
// Whenever target bounds change, inform the viewModel
|
|
||||||
useEffect(() => {
|
|
||||||
if (targetWidth > 0 && targetHeight > 0) {
|
|
||||||
vm.setTargetDimensions(targetWidth, targetHeight);
|
|
||||||
}
|
|
||||||
}, [targetWidth, targetHeight, vm]);
|
|
||||||
|
|
||||||
const baseProps: SpotlightUserMediaItemBaseProps &
|
const baseProps: SpotlightUserMediaItemBaseProps &
|
||||||
RefAttributes<HTMLDivElement> = {
|
RefAttributes<HTMLDivElement> = {
|
||||||
videoFit,
|
setVideoAspectRatio: vm.setVideoAspectRatio,
|
||||||
videoEnabled,
|
videoEnabled,
|
||||||
soundWaves: props.background === "transparent" ? speaking : undefined,
|
soundWaves: props.background === "transparent" ? speaking : undefined,
|
||||||
targetWidth,
|
targetWidth,
|
||||||
@@ -227,7 +219,6 @@ const SpotlightRingingMediaItem: FC<SpotlightRingingMediaItemProps> = ({
|
|||||||
}
|
}
|
||||||
avatarStyle="translucent"
|
avatarStyle="translucent"
|
||||||
videoEnabled={false}
|
videoEnabled={false}
|
||||||
videoFit="cover"
|
|
||||||
mirror={false}
|
mirror={false}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,259 +5,106 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import {
|
|
||||||
LocalTrack,
|
|
||||||
type LocalTrackPublication,
|
|
||||||
type RemoteTrackPublication,
|
|
||||||
Track,
|
|
||||||
} from "livekit-client";
|
|
||||||
|
|
||||||
import { ObservableScope } from "../state/ObservableScope";
|
import { autoVideoFit } from "./videoFit";
|
||||||
import { videoFit$, videoSizeFromParticipant$ } from "./videoFit";
|
|
||||||
import { constant } from "../state/Behavior";
|
|
||||||
import {
|
|
||||||
flushPromises,
|
|
||||||
mockLocalParticipant,
|
|
||||||
mockRemoteParticipant,
|
|
||||||
} from "./test";
|
|
||||||
|
|
||||||
describe("videoFit$ defaults", () => {
|
describe("videoFit$ defaults", () => {
|
||||||
test.each([
|
test.each([
|
||||||
{
|
{
|
||||||
videoSize: { width: 1920, height: 1080 },
|
videoAspectRatio: 1920 / 1080,
|
||||||
tileSize: undefined,
|
tileAspectRatio: NaN,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
videoSize: { width: 1080, height: 1920 },
|
videoAspectRatio: 1080 / 1920,
|
||||||
tileSize: undefined,
|
tileAspectRatio: NaN,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
videoSize: undefined,
|
videoAspectRatio: NaN,
|
||||||
tileSize: { width: 1920, height: 1080 },
|
tileAspectRatio: 1920 / 1080,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
videoSize: undefined,
|
videoAspectRatio: NaN,
|
||||||
tileSize: { width: 1080, height: 1920 },
|
tileAspectRatio: 1080 / 1920,
|
||||||
},
|
},
|
||||||
])(
|
])(
|
||||||
"videoFit$ returns `cover` when videoSize is $videoSize and tileSize is $tileSize",
|
"videoFit$ returns `cover` when videoAspectRatio is $videoAspectRatio and tileAspectRatio is $tileAspectRatio",
|
||||||
({ videoSize, tileSize }) => {
|
({ videoAspectRatio, tileAspectRatio }) =>
|
||||||
const scope = new ObservableScope();
|
expect(autoVideoFit(videoAspectRatio, tileAspectRatio)).toBe("cover"),
|
||||||
const videoSize$ = constant(videoSize);
|
|
||||||
const tileSize$ = constant(tileSize);
|
|
||||||
|
|
||||||
const fit = videoFit$(scope, videoSize$, tileSize$);
|
|
||||||
expect(fit.value).toBe("cover");
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const VIDEO_480_L = { width: 640, height: 480 };
|
const VIDEO_480_L = 640 / 480;
|
||||||
const VIDEO_720_L = { width: 1280, height: 720 };
|
const VIDEO_720_L = 1280 / 720;
|
||||||
const VIDEO_1080_L = { width: 1920, height: 1080 };
|
const VIDEO_1080_L = 1920 / 1080;
|
||||||
|
|
||||||
// Some sizes from real world testing, which don't match the standard video sizes exactly
|
// Some sizes from real world testing, which don't match the standard video sizes exactly
|
||||||
const TILE_SIZE_1_L = { width: 180, height: 135 };
|
const TILE_SIZE_1_L = 180 / 135;
|
||||||
const TILE_SIZE_3_P = { width: 379, height: 542 };
|
const TILE_SIZE_3_P = 379 / 542;
|
||||||
const TILE_SIZE_4_L = { width: 957, height: 542 };
|
const TILE_SIZE_4_L = 957 / 542;
|
||||||
// This is the size of an iPhone Xr in portrait mode
|
// This is the size of an iPhone Xr in portrait mode
|
||||||
const TILE_SIZE_5_P = { width: 414, height: 896 };
|
const TILE_SIZE_5_P = 414 / 896;
|
||||||
|
|
||||||
export function invertSize(size: { width: number; height: number }): {
|
function inverse(ratio: number): number {
|
||||||
width: number;
|
return 1 / ratio;
|
||||||
height: number;
|
|
||||||
} {
|
|
||||||
return {
|
|
||||||
width: size.height,
|
|
||||||
height: size.width,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
{
|
{
|
||||||
videoSize: VIDEO_480_L,
|
videoAspectRatio: VIDEO_480_L,
|
||||||
tileSize: TILE_SIZE_1_L,
|
tileAspectRatio: TILE_SIZE_1_L,
|
||||||
expected: "cover",
|
expected: "cover",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
videoSize: invertSize(VIDEO_480_L),
|
videoAspectRatio: inverse(VIDEO_480_L),
|
||||||
tileSize: TILE_SIZE_1_L,
|
tileAspectRatio: TILE_SIZE_1_L,
|
||||||
expected: "contain",
|
expected: "contain",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
videoSize: VIDEO_720_L,
|
videoAspectRatio: VIDEO_720_L,
|
||||||
tileSize: TILE_SIZE_4_L,
|
tileAspectRatio: TILE_SIZE_4_L,
|
||||||
expected: "cover",
|
expected: "cover",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
videoSize: invertSize(VIDEO_720_L),
|
videoAspectRatio: inverse(VIDEO_720_L),
|
||||||
tileSize: TILE_SIZE_4_L,
|
tileAspectRatio: TILE_SIZE_4_L,
|
||||||
expected: "contain",
|
expected: "contain",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
videoSize: invertSize(VIDEO_1080_L),
|
videoAspectRatio: inverse(VIDEO_1080_L),
|
||||||
tileSize: TILE_SIZE_3_P,
|
tileAspectRatio: TILE_SIZE_3_P,
|
||||||
expected: "cover",
|
expected: "cover",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
videoSize: VIDEO_1080_L,
|
videoAspectRatio: VIDEO_1080_L,
|
||||||
tileSize: TILE_SIZE_5_P,
|
tileAspectRatio: TILE_SIZE_5_P,
|
||||||
expected: "contain",
|
expected: "contain",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
videoSize: invertSize(VIDEO_1080_L),
|
videoAspectRatio: inverse(VIDEO_1080_L),
|
||||||
tileSize: TILE_SIZE_5_P,
|
tileAspectRatio: TILE_SIZE_5_P,
|
||||||
expected: "cover",
|
expected: "cover",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// square video
|
// square video
|
||||||
videoSize: { width: 400, height: 400 },
|
videoAspectRatio: 400 / 400,
|
||||||
tileSize: VIDEO_480_L,
|
tileAspectRatio: VIDEO_480_L,
|
||||||
expected: "contain",
|
expected: "contain",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Should default to cover if the initial size is 0:0.
|
// Should default to cover if the initial size is 0:0.
|
||||||
// Or else it will cause a flash of "contain" mode until the real size is loaded, which can be jarring.
|
// Or else it will cause a flash of "contain" mode until the real size is loaded, which can be jarring.
|
||||||
videoSize: VIDEO_480_L,
|
videoAspectRatio: VIDEO_480_L,
|
||||||
tileSize: { width: 0, height: 0 },
|
tileAspectRatio: 0 / 0,
|
||||||
expected: "cover",
|
expected: "cover",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
videoSize: { width: 0, height: 0 },
|
videoAspectRatio: 0 / 0,
|
||||||
tileSize: VIDEO_480_L,
|
tileAspectRatio: VIDEO_480_L,
|
||||||
expected: "cover",
|
expected: "cover",
|
||||||
},
|
},
|
||||||
])(
|
])(
|
||||||
"videoFit$ returns $expected when videoSize is $videoSize and tileSize is $tileSize",
|
"videoFit$ returns $expected when videoAspectRatio is $videoAspectRatio and tileAspectRatio is $tileAspectRatio",
|
||||||
({ videoSize, tileSize, expected }) => {
|
({ videoAspectRatio, tileAspectRatio, expected }) =>
|
||||||
const scope = new ObservableScope();
|
expect(autoVideoFit(videoAspectRatio, tileAspectRatio)).toBe(expected),
|
||||||
const videoSize$ = constant(videoSize);
|
|
||||||
const tileSize$ = constant(tileSize);
|
|
||||||
|
|
||||||
const fit = videoFit$(scope, videoSize$, tileSize$);
|
|
||||||
expect(fit.value).toBe(expected);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
describe("extracting video size from participant stats", () => {
|
|
||||||
function createMockRtpStats(
|
|
||||||
isInbound: boolean,
|
|
||||||
props: Partial<RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats> = {},
|
|
||||||
): RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats {
|
|
||||||
const baseStats = {
|
|
||||||
id: "mock-stats-id",
|
|
||||||
timestamp: Date.now(),
|
|
||||||
type: isInbound ? "inbound-rtp" : "outbound-rtp",
|
|
||||||
kind: "video",
|
|
||||||
...props,
|
|
||||||
};
|
|
||||||
|
|
||||||
return baseStats as RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("get stats for local user", async () => {
|
|
||||||
const localParticipant = mockLocalParticipant({
|
|
||||||
identity: "@local:example.org:AAAAAA",
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockReport: RTCStatsReport = new Map([
|
|
||||||
[
|
|
||||||
"OT01V639885149",
|
|
||||||
createMockRtpStats(false, {
|
|
||||||
frameWidth: 1280,
|
|
||||||
frameHeight: 720,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
const track = {
|
|
||||||
source: Track.Source.Camera,
|
|
||||||
getRTCStatsReport: vi
|
|
||||||
.fn()
|
|
||||||
.mockImplementation(async () => Promise.resolve(mockReport)),
|
|
||||||
} as Partial<LocalTrack> as LocalTrack;
|
|
||||||
|
|
||||||
// Set up the prototype chain (there is an instanceof check in getRTCStatsReport)
|
|
||||||
Object.setPrototypeOf(track, LocalTrack.prototype);
|
|
||||||
|
|
||||||
localParticipant.getTrackPublication = vi
|
|
||||||
.fn()
|
|
||||||
.mockImplementation((source: Track.Source) => {
|
|
||||||
if (source === Track.Source.Camera) {
|
|
||||||
return {
|
|
||||||
track,
|
|
||||||
} as unknown as LocalTrackPublication;
|
|
||||||
} else {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const videoDimensions$ = videoSizeFromParticipant$(
|
|
||||||
constant(localParticipant),
|
|
||||||
);
|
|
||||||
|
|
||||||
const publishedDimensions: { width: number; height: number }[] = [];
|
|
||||||
videoDimensions$.subscribe((dimensions) => {
|
|
||||||
if (dimensions) publishedDimensions.push(dimensions);
|
|
||||||
});
|
|
||||||
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
const dimension = publishedDimensions.pop();
|
|
||||||
expect(dimension).toEqual({ width: 1280, height: 720 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("get stats for remote user", async () => {
|
|
||||||
// vi.useFakeTimers()
|
|
||||||
const remoteParticipant = mockRemoteParticipant({
|
|
||||||
identity: "@bob:example.org:AAAAAA",
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockReport: RTCStatsReport = new Map([
|
|
||||||
[
|
|
||||||
"OT01V639885149",
|
|
||||||
createMockRtpStats(true, {
|
|
||||||
frameWidth: 480,
|
|
||||||
frameHeight: 640,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
const track = {
|
|
||||||
source: Track.Source.Camera,
|
|
||||||
getRTCStatsReport: vi
|
|
||||||
.fn()
|
|
||||||
.mockImplementation(async () => Promise.resolve(mockReport)),
|
|
||||||
} as Partial<LocalTrack> as LocalTrack;
|
|
||||||
|
|
||||||
// Set up the prototype chain (there is an instanceof check in getRTCStatsReport)
|
|
||||||
Object.setPrototypeOf(track, LocalTrack.prototype);
|
|
||||||
|
|
||||||
remoteParticipant.getTrackPublication = vi
|
|
||||||
.fn()
|
|
||||||
.mockImplementation((source: Track.Source) => {
|
|
||||||
if (source === Track.Source.Camera) {
|
|
||||||
return {
|
|
||||||
track,
|
|
||||||
} as unknown as RemoteTrackPublication;
|
|
||||||
} else {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const videoDimensions$ = videoSizeFromParticipant$(
|
|
||||||
constant(remoteParticipant),
|
|
||||||
);
|
|
||||||
|
|
||||||
const publishedDimensions: { width: number; height: number }[] = [];
|
|
||||||
videoDimensions$.subscribe((dimensions) => {
|
|
||||||
if (dimensions) publishedDimensions.push(dimensions);
|
|
||||||
});
|
|
||||||
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
const dimension = publishedDimensions.pop();
|
|
||||||
expect(dimension).toEqual({ width: 480, height: 640 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -5,107 +5,27 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { combineLatest, map, type Observable, of, switchMap } from "rxjs";
|
|
||||||
import {
|
|
||||||
type LocalParticipant,
|
|
||||||
type RemoteParticipant,
|
|
||||||
Track,
|
|
||||||
} from "livekit-client";
|
|
||||||
|
|
||||||
import { type ObservableScope } from "../state/ObservableScope.ts";
|
|
||||||
import { type Behavior } from "../state/Behavior.ts";
|
|
||||||
import {
|
|
||||||
observeInboundRtpStreamStats$,
|
|
||||||
observeOutboundRtpStreamStats$,
|
|
||||||
} from "../state/media/observeRtpStreamStats";
|
|
||||||
|
|
||||||
type Size = {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes the appropriate video fit mode ("cover" or "contain") based on the aspect ratios of the video and the tile.
|
* Computes the appropriate video fit mode ("cover" or "contain") based on the aspect ratios of the video and the tile.
|
||||||
* - If the video and tile have the same orientation (both landscape or both portrait), we use "cover" to fill the tile, even if it means cropping.
|
* - If the video and tile have the same orientation (both landscape or both portrait), we use "cover" to fill the tile, even if it means cropping.
|
||||||
* - If the video and tile have different orientations, we use "contain" to ensure the entire video is visible, even if it means letterboxing (black bars).
|
* - If the video and tile have different orientations, we use "contain" to ensure the entire video is visible, even if it means letterboxing (black bars).
|
||||||
* @param scope - the ObservableScope to create the Behavior in
|
|
||||||
* @param videoSize$ - an Observable of the video size (width and height) or undefined if the size is not yet known (no data yet received).
|
|
||||||
* @param tileSize$ - an Observable of the tile size (width and height) or undefined if the size is not yet known (not yet rendered).
|
|
||||||
*/
|
*/
|
||||||
export function videoFit$(
|
export function autoVideoFit(
|
||||||
scope: ObservableScope,
|
videoAspectRatio: number,
|
||||||
videoSize$: Observable<Size | undefined>,
|
tileAspectRatio: number,
|
||||||
tileSize$: Observable<Size | undefined>,
|
): "cover" | "contain" {
|
||||||
): Behavior<"cover" | "contain"> {
|
if (Number.isNaN(videoAspectRatio) || Number.isNaN(tileAspectRatio)) {
|
||||||
const fit$ = combineLatest([videoSize$, tileSize$]).pipe(
|
// If we have invalid sizes (e.g. useMeasure returns 0×0 on an initial render),
|
||||||
map(([videoSize, tileSize]) => {
|
// default to cover to avoid black bars.
|
||||||
if (!videoSize || !tileSize) {
|
return "cover";
|
||||||
// If we don't have the sizes, default to cover to avoid black bars.
|
}
|
||||||
// This is a reasonable default as it will ensure the video fills the tile, even if it means cropping.
|
|
||||||
return "cover";
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
videoSize.width === 0 ||
|
|
||||||
videoSize.height === 0 ||
|
|
||||||
tileSize.width === 0 ||
|
|
||||||
tileSize.height === 0
|
|
||||||
) {
|
|
||||||
// If we have invalid sizes (e.g. width or height is 0), default to cover to avoid black bars.
|
|
||||||
return "cover";
|
|
||||||
}
|
|
||||||
const videoAspectRatio = videoSize.width / videoSize.height;
|
|
||||||
const tileAspectRatio = tileSize.width / tileSize.height;
|
|
||||||
|
|
||||||
// If video is landscape (ratio > 1) and tile is portrait (ratio < 1) or vice versa,
|
// If video is landscape (ratio > 1) and tile is portrait (ratio < 1) or vice versa,
|
||||||
// we want to use "contain" (fit) mode to avoid excessive cropping
|
// we want to use "contain" (fit) mode to avoid excessive cropping
|
||||||
const videoIsLandscape = videoAspectRatio > 1;
|
const videoIsLandscape = videoAspectRatio > 1;
|
||||||
const tileIsLandscape = tileAspectRatio > 1;
|
const tileIsLandscape = tileAspectRatio > 1;
|
||||||
|
|
||||||
// If the orientations are the same, use the cover mode (Preserves the aspect ratio, and the image fills the container.)
|
// If the orientations are the same, use the cover mode (Preserves the aspect ratio, and the image fills the container.)
|
||||||
// If they're not the same orientation, use the contain mode (Preserves the aspect ratio, but the image is letterboxed - black bars- to fit within the container.)
|
// If they're not the same orientation, use the contain mode (Preserves the aspect ratio, but the image is letterboxed - black bars- to fit within the container.)
|
||||||
return videoIsLandscape === tileIsLandscape ? "cover" : "contain";
|
return videoIsLandscape === tileIsLandscape ? "cover" : "contain";
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return scope.behavior(fit$, "cover");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to get the video size from a participant.
|
|
||||||
* It observes the participant's video track stats and extracts the frame width and height.
|
|
||||||
* @param participant$ - an Observable of a LocalParticipant or RemoteParticipant, or null if no participant is selected.
|
|
||||||
* @returns an Observable of the video size (width and height) or undefined if the size cannot be determined.
|
|
||||||
*/
|
|
||||||
export function videoSizeFromParticipant$(
|
|
||||||
participant$: Observable<LocalParticipant | RemoteParticipant | null>,
|
|
||||||
): Observable<{ width: number; height: number } | undefined> {
|
|
||||||
return participant$
|
|
||||||
.pipe(
|
|
||||||
// If we have a participant, observe their video track stats. If not, return undefined.
|
|
||||||
switchMap((p) => {
|
|
||||||
if (!p) return of(undefined);
|
|
||||||
if (p.isLocal) {
|
|
||||||
return observeOutboundRtpStreamStats$(p, Track.Source.Camera);
|
|
||||||
} else {
|
|
||||||
return observeInboundRtpStreamStats$(p, Track.Source.Camera);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.pipe(
|
|
||||||
// Extract the frame width and height from the stats. If we don't have valid stats, return undefined.
|
|
||||||
map((stats) => {
|
|
||||||
if (!stats) return undefined;
|
|
||||||
if (
|
|
||||||
// For video tracks, frameWidth and frameHeight should be numbers. If they're not, we can't determine the size.
|
|
||||||
typeof stats.frameWidth !== "number" ||
|
|
||||||
typeof stats.frameHeight !== "number"
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
width: stats.frameWidth,
|
|
||||||
height: stats.frameHeight,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user