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