face detection & firestore & adding images to the messages
This commit is contained in:
7
package-lock.json
generated
7
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googlemaps/js-api-loader": "^2.1.0",
|
"@googlemaps/js-api-loader": "^2.1.0",
|
||||||
|
"@mediapipe/tasks-vision": "^0.10.35",
|
||||||
"firebase": "^12.14.0",
|
"firebase": "^12.14.0",
|
||||||
"ngeohash": "^0.6.3"
|
"ngeohash": "^0.6.3"
|
||||||
},
|
},
|
||||||
@@ -755,6 +756,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mediapipe/tasks-vision": {
|
||||||
|
"version": "0.10.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
|
||||||
|
"integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googlemaps/js-api-loader": "^2.1.0",
|
"@googlemaps/js-api-loader": "^2.1.0",
|
||||||
|
"@mediapipe/tasks-vision": "^0.10.35",
|
||||||
"firebase": "^12.14.0",
|
"firebase": "^12.14.0",
|
||||||
"ngeohash": "^0.6.3"
|
"ngeohash": "^0.6.3"
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/lib/Firebase/storage.js
Normal file
10
src/lib/Firebase/storage.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage'
|
||||||
|
import { storage } from './config.js';
|
||||||
|
|
||||||
|
export async function uploadImage(file) {
|
||||||
|
//create unique file name and stores them in one folder in firebase
|
||||||
|
const filename = `messages/${Date.now()}_${file.name}`;
|
||||||
|
const storageRef = ref(storage, filename);
|
||||||
|
await uploadBytes(storageRef, file);
|
||||||
|
return getDownloadURL(storageRef);
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@
|
|||||||
{#if message}
|
{#if message}
|
||||||
<div class="handle"> </div>
|
<div class="handle"> </div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
{#if message.imageUrl}
|
||||||
|
<img class="message-img" src={message.imageUrl} alt="message attachment" />
|
||||||
|
{/if}
|
||||||
<p class="message-text">{message.text}</p>
|
<p class="message-text">{message.text}</p>
|
||||||
{#if decay}
|
{#if decay}
|
||||||
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
|
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
|
||||||
@@ -114,6 +117,14 @@
|
|||||||
animation: pulsate 1.5s ease-in-out 3;
|
animation: pulsate 1.5s ease-in-out 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-img{
|
||||||
|
width: 100%;
|
||||||
|
max-height: 220px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes pulsate {
|
@keyframes pulsate {
|
||||||
0%, 100% {box-shadow: 0 0 0 0 rgba(78,205,196,0.4); }
|
0%, 100% {box-shadow: 0 0 0 0 rgba(78,205,196,0.4); }
|
||||||
50% {box-shadow: 0 0 0 12px rgba(78, 205, 196, 0);}
|
50% {box-shadow: 0 0 0 12px rgba(78, 205, 196, 0);}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
import { mapStore } from '$lib/stores/mapStore.js';
|
import { mapStore } from '$lib/stores/mapStore.js';
|
||||||
import { messagesStore } from '$lib/stores/messagesStore.js';
|
import { messagesStore } from '$lib/stores/messagesStore.js';
|
||||||
import { addMessage } from '$lib/firebase/messages.js'
|
import { addMessage } from '$lib/firebase/messages.js'
|
||||||
|
import { hasFace } from '$lib/utils/faceDetection.js';
|
||||||
|
import { uploadImage } from '$lib/Firebase/storage.js';
|
||||||
|
|
||||||
|
|
||||||
let {lat, lng} = $props();
|
let {lat, lng} = $props();
|
||||||
|
|
||||||
@@ -9,23 +12,78 @@
|
|||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
let remaining = $derived(240-text.length);
|
let remaining = $derived(240-text.length);
|
||||||
|
|
||||||
|
let selectedFile = $state(null);
|
||||||
|
let imagePreview = $state(null); // what <img> will read
|
||||||
|
let imageError = $state(null);
|
||||||
|
let checkingFace = $state(false); // this will show the checking image... message
|
||||||
|
|
||||||
|
async function handleImageSelect(event) {
|
||||||
|
const file = event.target.files[0]; // only one file will be allowed so always index 0
|
||||||
|
|
||||||
|
if (!file) return; // no file do nothing
|
||||||
|
checkingFace = true; // show loading state
|
||||||
|
imageError = null; // clear errors
|
||||||
|
|
||||||
|
try {
|
||||||
|
// run face detection
|
||||||
|
const faceFound = await hasFace(file);
|
||||||
|
|
||||||
|
if (faceFound) {
|
||||||
|
// no upload & error
|
||||||
|
imageError = 'Images may not contain faces. Please choose another photo.';
|
||||||
|
|
||||||
|
//reset file input for other attempt
|
||||||
|
event.target.value = '';
|
||||||
|
selectedFile= null;
|
||||||
|
imagePreview = null;
|
||||||
|
} else {
|
||||||
|
// no face we move on
|
||||||
|
selectedFile = file;
|
||||||
|
// create preview
|
||||||
|
imagePreview = URL.createObjectURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
// fixed: previously allowed the upload on any detection error — wrong for a
|
||||||
|
// face-blocking feature. Now we surface the error and block the upload instead.
|
||||||
|
console.error('[faceDetection] error:', err);
|
||||||
|
imageError = 'Could not verify the image. Please try a different photo.';
|
||||||
|
event.target.value = '';
|
||||||
|
selectedFile = null;
|
||||||
|
imagePreview = null;
|
||||||
|
} finally {
|
||||||
|
// no matter what turn the loading state off
|
||||||
|
checkingFace = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
|
// no submit with no text or text over limit
|
||||||
if (!text.trim() || remaining < 0) return;
|
if (!text.trim() || remaining < 0) return;
|
||||||
|
|
||||||
submitting = true;
|
submitting = true;
|
||||||
|
let imageUrl = ''; // stays empty if no image
|
||||||
|
|
||||||
await addMessage(lat, lng, text.trim());
|
if (selectedFile) {
|
||||||
|
imageUrl = await uploadImage(selectedFile);
|
||||||
|
}
|
||||||
|
|
||||||
//refresh the messages store so pin is there
|
await addMessage(lat, lng, text.trim(), imageUrl);
|
||||||
|
|
||||||
|
// reload pins so that they show up with new message
|
||||||
const { getNearbyMessages } = await import ('$lib/firebase/messages.js');
|
const { getNearbyMessages } = await import ('$lib/firebase/messages.js');
|
||||||
const updated = await getNearbyMessages(lat, lng);
|
const updated = await getNearbyMessages(lat, lng);
|
||||||
messagesStore.set(updated);
|
messagesStore.set(updated);
|
||||||
|
|
||||||
//reset and close
|
// reset compose state
|
||||||
text = '';
|
text = '';
|
||||||
|
selectedFile = null;
|
||||||
|
imagePreview = null;
|
||||||
submitting = false;
|
submitting = false;
|
||||||
|
|
||||||
|
// close the sheet
|
||||||
mapStore.set({selectedMessage: null, composing:false});
|
mapStore.set({selectedMessage: null, composing:false});
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="compose" class:visible={true}>
|
<div class="compose" class:visible={true}>
|
||||||
@@ -45,6 +103,35 @@
|
|||||||
rows="5"
|
rows="5"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
|
<div class = "image-section">
|
||||||
|
{#if checkingFace}
|
||||||
|
<p class="checking">Checking image...</p>
|
||||||
|
|
||||||
|
{:else if imagePreview}
|
||||||
|
<div class="preview-wrap">
|
||||||
|
<img class="preview" src={imagePreview} alt="preview" />
|
||||||
|
|
||||||
|
<button class="remove-image" onclick={() => { selectedFile = null; imagePreview = null; }}>
|
||||||
|
X
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
{#if imageError}
|
||||||
|
<p class="image-error">{imageError}</p>
|
||||||
|
{/if}
|
||||||
|
<label class="image-label">
|
||||||
|
+ add photo
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onchange={handleImageSelect}
|
||||||
|
style="display:none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="submit"
|
class="submit"
|
||||||
onclick={handleSubmit}
|
onclick={handleSubmit}
|
||||||
@@ -134,5 +221,60 @@
|
|||||||
background: #ccc;
|
background: #ccc;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-section {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-label {
|
||||||
|
display: inline-block;
|
||||||
|
paddingL 0.5rem 1rem;
|
||||||
|
border: 1.5px dashed #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-label:hover {
|
||||||
|
border-color: #111;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-image {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checking {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-error {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -13,23 +13,18 @@
|
|||||||
|
|
||||||
let markers = []; // keep track of pins on map
|
let markers = []; // keep track of pins on map
|
||||||
let userMarker;
|
let userMarker;
|
||||||
|
let AdvancedMarkerElement;
|
||||||
|
|
||||||
/** Jisu Legacy - 내 위치 마커 (메시지 핀과 구분되는 파란 점) */
|
/** Jisu Legacy - 내 위치 마커 (메시지 핀과 구분되는 파란 점) */
|
||||||
function addUserLocationMarker(map, centerLat, centerLng) {
|
function addUserLocationMarker(map, centerLat, centerLng) {
|
||||||
userMarker = new google.maps.Marker({
|
const dot = document.createElement('div');
|
||||||
|
dot.style.cssText = 'width:20px;height:20px;border-radius:50%;background:#4285F4;border:3px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,0.3)';
|
||||||
|
userMarker = new AdvancedMarkerElement({
|
||||||
position: { lat: centerLat, lng: centerLng },
|
position: { lat: centerLat, lng: centerLng },
|
||||||
map,
|
map,
|
||||||
title: 'Your location',
|
title: 'Your location',
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
icon: {
|
content: dot
|
||||||
path: google.maps.SymbolPath.CIRCLE,
|
|
||||||
scale: 10,
|
|
||||||
fillColor: '#4285F4',
|
|
||||||
fillOpacity: 1,
|
|
||||||
strokeColor: '#ffffff',
|
|
||||||
strokeWeight: 3
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,35 +39,37 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { Map } = await importLibrary('maps');
|
const { Map } = await importLibrary('maps');
|
||||||
|
({ AdvancedMarkerElement } = await importLibrary('marker'));
|
||||||
|
|
||||||
mapDiv = new Map(mapDiv, {
|
mapDiv = new Map(mapDiv, {
|
||||||
center: { lat: centerLat, lng: centerLng },
|
center: { lat: centerLat, lng: centerLng },
|
||||||
zoom: 15,
|
zoom: 15,
|
||||||
disableDefaultUI: true,
|
disableDefaultUI: true,
|
||||||
gestureHandling: 'greedy'
|
gestureHandling: 'greedy',
|
||||||
|
mapId: 'DEMO_MAP_ID'
|
||||||
});
|
});
|
||||||
|
|
||||||
addUserLocationMarker(mapDiv, centerLat, centerLng);
|
addUserLocationMarker(mapDiv, centerLat, centerLng);
|
||||||
});
|
});
|
||||||
|
|
||||||
// function to rended pins
|
// function to render pins
|
||||||
function renderPins(messages) {
|
function renderPins(messages) {
|
||||||
// clear current pins
|
// clear current pins
|
||||||
markers.forEach(marker => marker.setMap(null)); // make them not show up
|
markers.forEach(marker => (marker.map = null));
|
||||||
markers = []; // reset the array
|
markers = [];
|
||||||
|
|
||||||
messages.forEach(message => {
|
messages.forEach(message => {
|
||||||
const marker = new google.maps.Marker({
|
const marker = new AdvancedMarkerElement({
|
||||||
position: { lat: message.lat, lng: message.lng}, // lat and lng is what is called in the firestore documents
|
position: { lat: message.lat, lng: message.lng },
|
||||||
map: mapDiv,
|
map: mapDiv,
|
||||||
title: message.text // firestore field for messages
|
title: message.text
|
||||||
});
|
});
|
||||||
|
|
||||||
marker.addListener('click', () => {
|
marker.addEventListener('click', () => {
|
||||||
mapStore.set({ selectedMessage: message, composing: false}); //it updated the message object
|
mapStore.set({ selectedMessage: message, composing: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
markers.push(marker); // add the new pin to the array
|
markers.push(marker);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
<div class = "panel">
|
<div class = "panel">
|
||||||
{#if message}
|
{#if message}
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
{#if message.imageUrl}
|
||||||
|
<img class="message-img" src={message.imageUrl} alt="message attachment" />
|
||||||
|
{/if}
|
||||||
<p class="message-text">{message.text}</p>
|
<p class="message-text">{message.text}</p>
|
||||||
{#if decay}
|
{#if decay}
|
||||||
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
|
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
|
||||||
@@ -111,6 +114,14 @@
|
|||||||
animation: pulsate 1.5s ease-in-out 3;
|
animation: pulsate 1.5s ease-in-out 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-img{
|
||||||
|
width: 100%;
|
||||||
|
max-height: 220px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes pulsate {
|
@keyframes pulsate {
|
||||||
0%, 100% {box-shadow: 0 0 0 0 rgba(78,205,196,0.4); }
|
0%, 100% {box-shadow: 0 0 0 0 rgba(78,205,196,0.4); }
|
||||||
50% {box-shadow: 0 0 0 12px rgba(78, 205, 196, 0);}
|
50% {box-shadow: 0 0 0 12px rgba(78, 205, 196, 0);}
|
||||||
|
|||||||
44
src/lib/utils/faceDetection.js
Normal file
44
src/lib/utils/faceDetection.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { FaceDetector, FilesetResolver } from '@mediapipe/tasks-vision';
|
||||||
|
|
||||||
|
let faceDetector = null;
|
||||||
|
|
||||||
|
export async function loadFaceDetector() {
|
||||||
|
if (faceDetector) return;
|
||||||
|
|
||||||
|
console.log('[faceDetection] loading model...');
|
||||||
|
const vision = await FilesetResolver.forVisionTasks(
|
||||||
|
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm'
|
||||||
|
);
|
||||||
|
|
||||||
|
faceDetector = await FaceDetector.createFromOptions(vision, {
|
||||||
|
baseOptions: {
|
||||||
|
modelAssetPath: 'https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite'
|
||||||
|
},
|
||||||
|
runningMode: 'IMAGE'
|
||||||
|
});
|
||||||
|
console.log('[faceDetection] model ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixed: parameter was named 'File' (capital F) but used as 'file' below — caused ReferenceError
|
||||||
|
// that fell silently into the ComposeSheet catch block, allowing every upload through
|
||||||
|
export async function hasFace(file) {
|
||||||
|
await loadFaceDetector();
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
const img = document.createElement('img');
|
||||||
|
|
||||||
|
// fixed: onload/onerror must be assigned BEFORE setting img.src — if the object URL
|
||||||
|
// resolves synchronously the load event fires before the handler was attached and
|
||||||
|
// the Promise never settles, leaving detection running on an unloaded element
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
img.onload = resolve;
|
||||||
|
img.onerror = () => reject(new Error('Image failed to load'));
|
||||||
|
img.src = objectUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = faceDetector.detect(img);
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
|
||||||
|
console.log('[faceDetection] detections:', result.detections);
|
||||||
|
return result.detections.length > 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user