Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add 3D Face Effects Twilio Video Processor and Menu #768

Open
wants to merge 20 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5ca66f3
Add face effects button to the menu.
youneslaa7878 Nov 12, 2022
0698cc4
Add face effect selection dialog state to video context.
youneslaa7878 Nov 12, 2022
1a3d2b7
Add a header to the mask selection dialog.
youneslaa7878 Nov 12, 2022
2537dfa
Add mask settings to video context.
youneslaa7878 Nov 12, 2022
9fd3a3b
Add thumbnails to the face masks dialog.
youneslaa7878 Nov 12, 2022
dd5b15f
Build face masks video processor.
youneslaa7878 Nov 13, 2022
2317e7f
Use the mask processor in the mask hook.
youneslaa7878 Nov 13, 2022
1ffb114
Build the face masks video processor and hook.
youneslaa7878 Nov 15, 2022
2b6569e
Rename files and give credits.
youneslaa7878 Nov 15, 2022
46881a2
Shrink room view when mask selection is open.
youneslaa7878 Nov 15, 2022
8702f22
Add more face masks
youneslaa7878 Nov 15, 2022
fb54fb7
Fix mask dialog title.
youneslaa7878 Nov 15, 2022
7a66481
Improve performance of face masks video processor.
youneslaa7878 Nov 15, 2022
c723f08
Change type of _camRegion from any to Region.
youneslaa7878 Nov 16, 2022
9edb00b
Rename MaskIcon component default export to MaskIcon.
youneslaa7878 Nov 16, 2022
4ceb0f3
Merge branch 'master' into face-effects
youneslaaroussi Nov 16, 2022
8ae1161
Add more tests to the useMaskSettings hook.
youneslaa7878 Nov 18, 2022
b1e6d70
Merge branch 'face-effects' of https://github.com/eludadev/twilio-3d-…
youneslaa7878 Nov 18, 2022
9ad288e
Merge branch 'master' into face-effects
youneslaaroussi Nov 27, 2022
4daf308
Merge branch 'master' into face-effects
youneslaaroussi Dec 3, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
356 changes: 285 additions & 71 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
"@material-ui/core": "^4.12.3",
"@material-ui/icons": "^4.11.2",
"@material-ui/lab": "^4.0.0-alpha.60",
"@tensorflow-models/face-landmarks-detection": "^1.0.2",
"@tensorflow/tfjs-backend-wasm": "^4.0.0",
"@tensorflow/tfjs-core": "^4.0.0",
"@twilio-labs/plugin-rtc": "^0.8.4",
"@twilio/conversations": "^2.1.0",
"@twilio/video-processors": "^1.0.1",
Expand Down
51 changes: 51 additions & 0 deletions src/components/MaskSelectionDialog/MaskSelectionDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React from 'react';
import MaskSelectionHeader from './MaskSelectionHeader/MaskSelectionHeader';
import MaskThumbnail from './MaskThumbnail/MaskThumbnail';
import Drawer from '@material-ui/core/Drawer';
import { makeStyles, Theme } from '@material-ui/core/styles';
import { maskConfig } from '../VideoProvider/useMaskSettings/useMaskSettings';
import useVideoContext from '../../hooks/useVideoContext/useVideoContext';

const useStyles = makeStyles((theme: Theme) => ({
drawer: {
display: 'flex',
width: theme.rightDrawerWidth,
height: `calc(100% - ${theme.footerHeight}px)`,
},
thumbnailContainer: {
display: 'flex',
flexWrap: 'wrap',
padding: '5px',
overflowY: 'auto',
},
}));

function MaskSelectionDialog() {
const classes = useStyles();
const { isMaskSelectionOpen, setIsMaskSelectionOpen } = useVideoContext();

const imageNames = maskConfig.imageNames;
const images = maskConfig.images;

return (
<Drawer
variant="persistent"
anchor="right"
open={isMaskSelectionOpen}
transitionDuration={0}
classes={{
paper: classes.drawer,
}}
>
<MaskSelectionHeader onClose={() => setIsMaskSelectionOpen(false)} />
<div className={classes.thumbnailContainer}>
<MaskThumbnail thumbnail={'none'} name={'None'} />
{images.map((image, index) => (
<MaskThumbnail thumbnail={'image'} name={imageNames[index]} index={index} imagePath={image} key={image} />
))}
</div>
</Drawer>
);
}

export default MaskSelectionDialog;
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from 'react';
import { shallow } from 'enzyme';
import CloseIcon from '../../../icons/CloseIcon';
import MaskSelectionHeader from './MaskSelectionHeader';

const mockCloseDialog = jest.fn();

describe('The Background Selection Header Component', () => {
it('should close the selection dialog when "X" is clicked', () => {
const wrapper = shallow(<MaskSelectionHeader onClose={mockCloseDialog} />);
wrapper
.find(CloseIcon)
.parent()
.simulate('click');
expect(mockCloseDialog).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React from 'react';
import { makeStyles, createStyles } from '@material-ui/core/styles';
import CloseIcon from '../../../icons/CloseIcon';

const useStyles = makeStyles(() =>
createStyles({
container: {
minHeight: '56px',
background: '#F4F4F6',
borderBottom: '1px solid #E4E7E9',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0 1em',
},
text: {
fontWeight: 'bold',
},
closeMaskSelection: {
cursor: 'pointer',
display: 'flex',
background: 'transparent',
border: '0',
padding: '0.4em',
},
})
);

interface MaskSelectionHeaderProps {
onClose: () => void;
}

export default function MaskSelectionHeader({ onClose }: MaskSelectionHeaderProps) {
const classes = useStyles();
return (
<div className={classes.container}>
<div className={classes.text}>Mask Effects</div>
<button className={classes.closeMaskSelection} onClick={onClose}>
<CloseIcon />
</button>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import React from 'react';
import MaskThumbnail from './MaskThumbnail';
import BlurIcon from '@material-ui/icons/BlurOnOutlined';
import NoneIcon from '@material-ui/icons/NotInterestedOutlined';
import { shallow } from 'enzyme';
import useVideoContext from '../../../hooks/useVideoContext/useVideoContext';

jest.mock('../../../hooks/useVideoContext/useVideoContext');
const mockUseVideoContext = useVideoContext as jest.Mock<any>;
const mockSetMaskSettings = jest.fn();
mockUseVideoContext.mockImplementation(() => ({
maskSettings: {
type: 'blur',
index: 0,
},
setMaskSettings: mockSetMaskSettings,
}));

describe('The MaskThumbnail component', () => {
it('should update the mask settings when clicked', () => {
const wrapper = shallow(<MaskThumbnail thumbnail={'none'} index={5} />);
wrapper.simulate('click');
expect(mockSetMaskSettings).toHaveBeenCalledWith({ index: 5, type: 'none' });
});

it('should not be selected when thumbnail prop and maskSettings type are not equivalent (icon)', () => {
const wrapper = shallow(<MaskThumbnail thumbnail={'none'} />);
expect(wrapper.find('.selected').exists()).toBe(false);
});

it('should be selected when thumbnail prop and maskSettings type are equivalent (image)', () => {
mockUseVideoContext.mockImplementationOnce(() => ({
maskSettings: {
type: 'image',
index: 1,
},
setMaskSettings: mockSetMaskSettings,
}));
const wrapper = shallow(<MaskThumbnail thumbnail={'image'} index={1} />);
expect(wrapper.find('.selected').exists()).toBe(true);
});

it('should not be selected when thumbnail and maskSettings type are not equivlanet (image)', () => {
mockUseVideoContext.mockImplementationOnce(() => ({
maskSettings: {
type: 'image',
index: 1,
},
setMaskSettings: mockSetMaskSettings,
}));
const wrapper = shallow(<MaskThumbnail thumbnail={'image'} index={5} />);
expect(wrapper.find('.selected').exists()).toBe(false);
});

it("should contain the NoneIcon when thumbnail is set to 'none'", () => {
const wrapper = shallow(<MaskThumbnail thumbnail={'none'} />);
expect(wrapper.containsMatchingElement(<NoneIcon />)).toBe(true);
});

it("should not have any icons when thumbnail is set to 'image'", () => {
const wrapper = shallow(<MaskThumbnail thumbnail={'image'} />);
expect(wrapper.containsMatchingElement(<BlurIcon />)).toBe(false);
expect(wrapper.containsMatchingElement(<NoneIcon />)).toBe(false);
});
});
132 changes: 132 additions & 0 deletions src/components/MaskSelectionDialog/MaskThumbnail/MaskThumbnail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import React from 'react';
import clsx from 'clsx';
import BlurIcon from '@material-ui/icons/BlurOnOutlined';
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
import NoneIcon from '@material-ui/icons/NotInterestedOutlined';
import useVideoContext from '../../../hooks/useVideoContext/useVideoContext';

export type Thumbnail = 'none' | 'image';

interface MaskThumbnailProps {
thumbnail: Thumbnail;
imagePath?: string;
name?: string;
index?: number;
}

const useStyles = makeStyles((theme: Theme) =>
createStyles({
thumbContainer: {
margin: '5px',
width: 'calc(50% - 10px)',
display: 'flex',
position: 'relative',
'&::after': {
content: '""',
paddingBottom: '55.5%',
},
},
thumbIconContainer: {
width: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
borderRadius: '10px',
border: `solid ${theme.palette.grey[400]}`,
'&.selected': {
border: `solid ${theme.palette.primary.main}`,
'& svg': {
color: `${theme.palette.primary.main}`,
},
},
},
thumbIcon: {
height: 50,
width: 50,
color: `${theme.palette.grey[400]}`,
'&.selected': {
color: `${theme.palette.primary.main}`,
},
},
thumbImage: {
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
objectFit: 'cover',
borderRadius: '10px',
border: `solid ${theme.palette.grey[400]}`,
'&:hover': {
cursor: 'pointer',
'& svg': {
color: `${theme.palette.primary.main}`,
},
'& $thumbOverlay': {
visibility: 'visible',
},
},
'&.selected': {
border: `solid ${theme.palette.primary.main}`,
'& svg': {
color: `${theme.palette.primary.main}`,
},
},
},
thumbOverlay: {
position: 'absolute',
color: 'transparent',
padding: '20px',
fontSize: '14px',
fontWeight: 'bold',
width: '100%',
height: '100%',
borderRadius: '10px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
'&:hover': {
background: 'rgba(95, 93, 128, 0.6)',
color: 'white',
},
},
})
);

export default function MaskThumbnail({ thumbnail, imagePath, name, index }: MaskThumbnailProps) {
const classes = useStyles();
const { maskSettings, setMaskSettings } = useVideoContext();
const isImage = thumbnail === 'image';
const thumbnailSelected = isImage
? maskSettings.index === index && maskSettings.type === 'image'
: maskSettings.type === thumbnail;
const icons = {
none: NoneIcon,
blur: BlurIcon,
image: null,
};
const ThumbnailIcon = icons[thumbnail];

return (
<div
className={classes.thumbContainer}
onClick={() =>
setMaskSettings({
type: thumbnail,
index: index,
})
}
>
{ThumbnailIcon ? (
<div className={clsx(classes.thumbIconContainer, { selected: thumbnailSelected })}>
<ThumbnailIcon className={classes.thumbIcon} />
</div>
) : (
<img className={clsx(classes.thumbImage, { selected: thumbnailSelected })} src={imagePath} alt={name} />
)}
<div className={classes.thumbOverlay}>{name}</div>
</div>
);
}
20 changes: 19 additions & 1 deletion src/components/MenuBar/Menu/Menu.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useState, useRef } from 'react';
import AboutDialog from '../../AboutDialog/AboutDialog';
import BackgroundIcon from '../../../icons/BackgroundIcon';
import MaskIcon from '../../../icons/MaskIcon';
import CollaborationViewIcon from '@material-ui/icons/AccountBox';
import DeviceSelectionDialog from '../../DeviceSelectionDialog/DeviceSelectionDialog';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
Expand Down Expand Up @@ -39,7 +40,7 @@ export default function Menu(props: { buttonClassName?: string }) {
const { isFetching, updateRecordingRules, roomType, setIsGalleryViewActive, isGalleryViewActive } = useAppState();
const { setIsChatWindowOpen } = useChatContext();
const isRecording = useIsRecording();
const { room, setIsBackgroundSelectionOpen } = useVideoContext();
const { room, setIsBackgroundSelectionOpen, setIsMaskSelectionOpen } = useVideoContext();

const anchorRef = useRef<HTMLButtonElement>(null);
const { flipCameraDisabled, toggleFacingMode, flipCameraSupported } = useFlipCameraToggle();
Expand Down Expand Up @@ -85,6 +86,7 @@ export default function Menu(props: { buttonClassName?: string }) {
<MenuItem
onClick={() => {
setIsBackgroundSelectionOpen(true);
setIsMaskSelectionOpen(false);
setIsChatWindowOpen(false);
setMenuOpen(false);
}}
Expand All @@ -96,6 +98,22 @@ export default function Menu(props: { buttonClassName?: string }) {
</MenuItem>
)}

{isSupported && (
<MenuItem
onClick={() => {
setIsBackgroundSelectionOpen(false);
setIsMaskSelectionOpen(true);
setIsChatWindowOpen(false);
setMenuOpen(false);
}}
>
<IconContainer>
<MaskIcon />
</IconContainer>
<Typography variant="body1">Face Effects</Typography>
</MenuItem>
)}

{flipCameraSupported && (
<MenuItem disabled={flipCameraDisabled} onClick={toggleFacingMode}>
<IconContainer>
Expand Down
Loading