1import Ionicons from '@expo/vector-icons/build/Ionicons'; 2import * as FaceDetector from 'expo-face-detector'; 3import * as VideoThumbnails from 'expo-video-thumbnails'; 4import React from 'react'; 5import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; 6 7const pictureSize = 150; 8 9type State = { 10 uri?: string; 11 selected: boolean; 12 faces: FaceDetector.FaceFeature[]; 13 image?: FaceDetector.Image; 14 isVideo: boolean; 15}; 16 17type Props = { 18 uri: string; 19 onSelectionToggle: (uri: string, selected: boolean) => void; 20}; 21 22export default class Photo extends React.Component<Props, State> { 23 readonly state: State = { 24 selected: false, 25 faces: [], 26 uri: undefined, 27 isVideo: false, 28 }; 29 _mounted = false; 30 31 componentDidMount() { 32 this._mounted = true; 33 34 if (this.props.uri.endsWith('jpg')) { 35 this.setState(() => ({ uri: this.props.uri })); 36 } else { 37 this.getVideoThumbnail(this.props.uri).then((uri) => 38 this.setState(() => ({ uri, isVideo: true })) 39 ); 40 } 41 } 42 43 componentWillUnmount() { 44 this._mounted = false; 45 } 46 47 toggleSelection = () => { 48 this.setState( 49 (state) => ({ selected: !state.selected }), 50 () => this.props.uri && this.props.onSelectionToggle(this.props.uri, this.state.selected) 51 ); 52 }; 53 54 detectFace = () => 55 this.state.uri && 56 FaceDetector.detectFacesAsync(this.state.uri, { 57 detectLandmarks: FaceDetector.FaceDetectorLandmarks.none, 58 runClassifications: FaceDetector.FaceDetectorClassifications.all, 59 }) 60 .then(this.facesDetected) 61 .catch(this.handleFaceDetectionError); 62 63 facesDetected = ({ 64 faces, 65 image, 66 }: { 67 faces: FaceDetector.FaceFeature[]; 68 image: FaceDetector.Image; 69 }) => { 70 this.setState({ 71 faces, 72 image, 73 }); 74 }; 75 76 getImageDimensions = ({ width, height }: FaceDetector.Image) => { 77 if (width > height) { 78 const scaledHeight = (pictureSize * height) / width; 79 return { 80 width: pictureSize, 81 height: scaledHeight, 82 83 scaleX: pictureSize / width, 84 scaleY: scaledHeight / height, 85 86 offsetX: 0, 87 offsetY: (pictureSize - scaledHeight) / 2, 88 }; 89 } else { 90 const scaledWidth = (pictureSize * width) / height; 91 return { 92 width: scaledWidth, 93 height: pictureSize, 94 95 scaleX: scaledWidth / width, 96 scaleY: pictureSize / height, 97 98 offsetX: (pictureSize - scaledWidth) / 2, 99 offsetY: 0, 100 }; 101 } 102 }; 103 104 handleFaceDetectionError = (error: any) => console.warn(error); 105 106 renderFaces = () => this.state.image && this.state.faces && this.state.faces.map(this.renderFace); 107 108 renderFace = (face: FaceDetector.FaceFeature, index: number) => { 109 const { scaleX, scaleY, offsetX, offsetY } = this.getImageDimensions(this.state.image!); 110 const layout = { 111 top: offsetY + face.bounds.origin.y * scaleY, 112 left: offsetX + face.bounds.origin.x * scaleX, 113 width: face.bounds.size.width * scaleX, 114 height: face.bounds.size.height * scaleY, 115 }; 116 117 return ( 118 <View 119 key={index} 120 style={[ 121 styles.face, 122 layout, 123 { 124 transform: [ 125 { perspective: 600 }, 126 { rotateZ: `${(face.rollAngle || 0).toFixed(0)}deg` }, 127 { rotateY: `${(face.yawAngle || 0).toFixed(0)}deg` }, 128 ], 129 }, 130 ]}> 131 {face.smilingProbability && ( 132 <Text style={styles.faceText}> {(face.smilingProbability * 100).toFixed(0)}%</Text> 133 )} 134 </View> 135 ); 136 }; 137 138 getVideoThumbnail = async (videoUri: string) => { 139 try { 140 const { uri } = await VideoThumbnails.getThumbnailAsync(videoUri, { time: 250 }); 141 return uri; 142 } catch (error) { 143 console.warn(error); 144 return undefined; 145 } 146 }; 147 148 render() { 149 const { uri } = this.state; 150 return ( 151 <TouchableOpacity 152 style={styles.pictureWrapper} 153 onLongPress={this.detectFace} 154 onPress={this.toggleSelection} 155 activeOpacity={1}> 156 <Image style={styles.picture} source={{ uri }} /> 157 {this.state.isVideo && ( 158 <Ionicons name="videocam" size={24} color="#ffffffbb" style={styles.videoIcon} /> 159 )} 160 {this.state.selected && <Ionicons name="md-checkmark-circle" size={30} color="#4630EB" />} 161 <View style={styles.facesContainer}>{this.renderFaces()}</View> 162 </TouchableOpacity> 163 ); 164 } 165} 166 167const styles = StyleSheet.create({ 168 picture: { 169 position: 'absolute', 170 bottom: 0, 171 right: 0, 172 left: 0, 173 top: 0, 174 resizeMode: 'contain', 175 }, 176 pictureWrapper: { 177 width: pictureSize, 178 height: pictureSize, 179 alignItems: 'center', 180 justifyContent: 'center', 181 margin: 5, 182 }, 183 facesContainer: { 184 position: 'absolute', 185 bottom: 0, 186 right: 0, 187 left: 0, 188 top: 0, 189 }, 190 face: { 191 borderWidth: 2, 192 borderRadius: 2, 193 position: 'absolute', 194 borderColor: '#FFD700', 195 justifyContent: 'center', 196 backgroundColor: 'rgba(0, 0, 0, 0.5)', 197 }, 198 faceText: { 199 color: '#FFD700', 200 fontWeight: 'bold', 201 textAlign: 'center', 202 margin: 2, 203 fontSize: 10, 204 backgroundColor: 'transparent', 205 }, 206 videoIcon: { 207 position: 'absolute', 208 bottom: 0, 209 right: 36, 210 }, 211}); 212