feat: implement imageProcessor.ts and fix fullscreen photo lightbox alignment

This commit is contained in:
2026-06-27 21:42:27 +07:00
parent 277f647e40
commit a1bf0d2c08
7 changed files with 424 additions and 254 deletions
-94
View File
@@ -1,94 +0,0 @@
# To AI Agent: Fix Mobile Dropdown Displacement and Eliminate `.filter` Array Runtime Crashes
## 1. Context & Identified Bugs
During Android Simulator testing, the frontend app encounters three critical layout and runtime execution failures:
1. **Dropdown Menu Displacement:** The profile/avatar menu container gets forced down to the extreme bottom of the viewport instead of floating as an absolute element directly beneath the user's top-bar avatar anchor.
2. **`Uncaught TypeError: g.filter is not a function`:** Triggering the Friend List components crashes the interface into a blank white screen.
3. **`Error fetching connections: TypeError: (intermediate value).filter is not a function`:** Opening the Live Chat view crashes identical array loops.
- *Root Cause for 2 & 3:* The API response payload from the backend server is **not returning a clean primitive Array**. It returns an object wrapper (e.g., `{ success: true, data: [...] }`) or `undefined`/`null` due to connection timing gaps. Invoking `.filter()` on a non-array instantly kills the React rendering thread.
---
## 2. Refactoring Blueprint
### Step 1: Fix Dropdown Positioning Context (`MapProfileDropdown.tsx`)
On desktop, absolute drop panels function normally. However, on mobile viewports or custom wrappers, they lose anchoring. We must force the container component to lock its coordinate space relative to the top bar element using modern CSS constraints:
```jsx
{/* ✅ RESPONSIVE REFACTOR: Force absolute rendering locked beneath the profile button context */}
<div
className="absolute right-0 top-14 w-[280px] bg-slate-900/95 backdrop-blur-md border border-slate-800 rounded-2xl p-2 shadow-2xl flex flex-col space-y-0.5 text-xs text-slate-200 z-[999999]"
style={{
maxHeight: '75vh',
overflowY: 'auto',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.6)'
}}
>
{/* Menu option items: Tạo tour, Hành trình của tôi, Thư viện ảnh... */}
</div>
### Step 2: Enforce Defensive Array Check on Friend List Loop (FriendsManagerModal.tsx)
Locate where the system handles friend collections. Implement an explicit array layout typecheck validation using Array.isArray() before doing any mutation logic:
// ❌ OLD ERROR-PRONE PATTERN:
// const activeFriends = data.filter(f => f.status === 'active');
// ✅ NEW IMPERATIVE PROTECTION:
const [friendsList, setFriendsList] = useState<any[]>([]);
useEffect(() => {
api.get('/friends/connections')
.then((res) => {
const payload = res.data;
// Defensively parse and normalize the input shape
if (payload && Array.isArray(payload)) {
setFriendsList(payload);
} else if (payload && Array.isArray(payload.data)) {
setFriendsList(payload.data); // Support nested response architectures safely
} else {
console.error("⚠️ Backend returned non-array structure:", payload);
setFriendsList([]); // Default fallback to shield downstream loops
}
})
.catch((err) => {
console.error("Error reading friends list data stream:", err);
setFriendsList([]);
});
}, []);
// Safely filter verified arrays exclusively
const activeFriends = Array.isArray(friendsList)
? friendsList.filter((f: any) => f && f.status === 'active')
: [];
### Step 3: Secure Live Chat Target Connections Handler (LiveChatModal.tsx)
Apply the exact same defensive architecture inside your real-time chat sync queries or global context connection monitors:
const fetchUserConnections = async () => {
try {
const response = await api.get('/chat/connections');
const responseBody = response.data;
// Check array schema context explicitly
const verifiedConnections = Array.isArray(responseBody)
? responseBody
: (responseBody && Array.isArray(responseBody.connections) ? responseBody.connections : []);
/* ✅ CRITICAL SEPARATION: Running filter only on verified array collections */
const onlineConnections = verifiedConnections.filter((conn: any) => conn && conn.isOnline === true);
setConnections(verifiedConnections);
} catch (error) {
console.error("[LiveChatModal] Runtime error fetching message feeds intercepted gracefully:", error);
setConnections([]); // Initialize to fallback empty context
}
};
## 3. Automated Verification Checklist for AI Agent
[ ] Dropdown Anchor Alignment: Confirm the avatar profile drawer panel renders right under the top-bar avatar, leaving the bottom interaction tools untouched.
[ ] White Screen Eradication: Mock an empty server exception response (500 Internal Error). Verify the client component stays functional, logs the incident, and updates the empty UI state without crashing.
[ ] Global Code Polish Check: Scan the repository to ensure no raw .filter() methods are executed directly against fetched network streams without preceding type guards.
+181
View File
@@ -0,0 +1,181 @@
# To AI Agent: Fix EXIF GPS Extraction, Implement Client-Side 2K Image Resizing, and Fix Android Fullscreen Lightbox Alignment
## 1. Context & Feature Objectives
We are addressing three crucial image-handling and layout bugs on the mobile/Android web wrapper:
1. **Fix (Missing EXIF Location):** When users upload photos, the system fails to extract the geographic coordinates (Latitude/Longitude) embedded within the image metadata. We need to parse EXIF data completely on the client side before submission.
2. **Feat (Native Save & 2K Downscale):** Whether the user shoots a new photo via the Camera or picks one from the Gallery, the original image must remain safely stored in the phone's native album (handled by native webview permissions). Before uploading the file to our Debian server, the frontend must dynamically resize/downscale the image to a maximum resolution of **2K (2048px on its longest edge)** to optimize network bandwidth and server storage.
3. **Bug (Fullscreen Viewer Displacement):** When clicking an image inside the gallery/photo manager view to preview it in fullscreen mode on an Android device, the image incorrectly aligns to the absolute bottom edge of the viewport instead of centering beautifully.
---
## 2. Technical Execution Strategy
### 2.1. Client-Side EXIF Processing & Metadata Preservation
Standard browser file inputs often strip EXIF headers during dynamic manipulation or fail to parse them natively. We will introduce `exif-js` or use a standard binary array buffer scanner to extract the `GPSLatitude` and `GPSLongitude` headers right before resizing occurs, attaching them to the final multipart upload payload.
### 2.2. Downscaling to 2K via HTML5 Canvas
To achieve hardware-accelerated image scaling on mobile devices without losing core image visibility, the source image will be rendered onto an offscreen `<canvas>` container configured to enforce a `max-dimension` of `2048px`, maintaining the original aspect ratio.
### 2.3. Flexbox/Absolute Centering Fix for Android Lightbox
The bottom-displacement bug is tied to incorrect layout bounds calculations on mobile screens when toolbars or navigation rows shift view heights. We will refactor the Lightbox container modal to use rigid viewport configurations (`fixed inset-0`) along with standard vertical centering mechanics.
---
## 3. Code Refactoring Blueprint
### Step 1: Implement Image Metadata Picker & 2K Resizer Logic (`imageProcessor.ts`)
Create a utility service at `frontend/src/utils/imageProcessor.ts` to handle metadata extraction and canvas downscaling sequentially:
```typescript
import EXIF from 'exif-js';
interface ProcessedImageResult {
file: Blob;
latitude: number | null;
longitude: number | null;
}
// Helper to convert EXIF rational coordinates to standard decimal degrees
const convertDMSToDD = (dms: number[], ref: string): number => {
if (!dms || dms.length < 3) return 0;
const degrees = dms[0] + dms[1] / 60 + dms[2] / 3600;
return ref === 'S' || ref === 'W' ? -degrees : degrees;
};
export const processAndResizeImage = (file: File): Promise<ProcessedImageResult> => {
return new Promise((resolve) => {
let latitude: number | null = null;
let longitude: number | null = null;
// 1. EXTRACT EXIF METADATA BEFORE CANVAS CLEARING
EXIF.getData(file as any, function (this: any) {
const allTags = EXIF.getAllTags(this);
if (allTags.GPSLatitude && allTags.GPSLatitudeRef) {
latitude = convertDMSToDD(allTags.GPSLatitude, allTags.GPSLatitudeRef);
}
if (allTags.GPSLongitude && allTags.GPSLongitudeRef) {
longitude = convertDMSToDD(allTags.GPSLongitude, allTags.GPSLongitudeRef);
}
console.log(`📸 Extracted EXIF Metadata - Lat: ${latitude}, Lng: ${longitude}`);
// Proceed directly to resizing stage
proceedToResize();
});
function proceedToResize() {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target?.result as string;
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
const MAX_SIZE = 2048; // Enforce rigid 2K maximum boundary limit
// Calculate ideal bounding proportions
if (width > height) {
if (width > MAX_SIZE) {
height = Math.round((height * MAX_SIZE) / width);
width = MAX_SIZE;
}
} else {
if (height > MAX_SIZE) {
width = Math.round((width * MAX_SIZE) / height);
height = MAX_SIZE;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return resolve({ file, latitude, longitude });
// Render image onto downscaled dimensions canvas bounding box
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
if (blob) {
resolve({
file: blob,
latitude,
longitude
});
} else {
resolve({ file, latitude, longitude });
}
}, 'image/jpeg', 0.88); // 88% quality compression sweet-spot
};
};
}
});
};
### Step 2: Update Image Upload Handler Layer
Integrate the processor wrapper inside your central upload function (e.g., ImageUploader.tsx or your form submission handler):
import { processAndResizeImage } from '../../utils/imageProcessor';
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const targetFile = event.target.files?.[0];
if (!targetFile) return;
try {
// 1. Process image: Reads EXIF data and forces 2K resizing on device memory
const { file, latitude, longitude } = await processAndResizeImage(targetFile);
const formData = new FormData();
formData.append('image', file, 'yotrip_upload.jpg');
// 2. Append coordinates safely to standard server fields
if (latitude !== null && longitude !== null) {
formData.append('latitude', latitude.toString());
formData.append('longitude', longitude.toString());
}
// 3. Post to API endpoint
const response = await api.post('/media/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
console.log("✅ Media successfully synchronized with server backend:", response.data);
} catch (error) {
console.error("Failed to safely prepare media stream:", error);
}
};
### Step 3: Fix Fullscreen Image Alignment Layout (ImageLightbox.tsx)
Locate your photo viewer overlay or modal drawer component. Overhaul the tailwind utilities to guarantee true vertical and horizontal centering layout balance on Android devices:
{/* ❌ BEFORE: Faulty container pinning images to device bottom edges */}
<div className="fixed inset-0 bg-black flex items-end justify-center">
{/* ✅ AFTER: True viewport overlay centering bounding context */}
<div className="fixed inset-0 bg-black/95 backdrop-blur-sm flex flex-col items-center justify-center z-[999999] overflow-hidden animate-fade-in">
{/* Close Button Top Tracker Bar Container */}
<div className="absolute top-4 right-4 z-50">
<button className="p-2.5 bg-slate-900/60 rounded-full text-white">✕</button>
</div>
{/* Image wrapper frame context forcing clean alignment metrics */}
<div className="w-full h-full flex items-center justify-center p-4">
<img
src={currentImageUrl}
alt="YoTrip Preview"
className="max-w-full max-h-full object-contain select-none pointer-events-auto"
style={{
/* Prevent Android webviews from accidental shifting behaviors */
transform: 'translate3d(0, 0, 0)'
}}
/>
</div>
</div>
## 4. Automated Verification Checklist for AI Agent
[ ] EXIF Validation Verification: Test uploading a photo embedded with active geolocation values. Inspect the API outgoing transmission payload in the network panel; latitude and longitude fields must contain accurate decimal metrics instead of blank string indicators.
[ ] Longest-Edge Constraint Check: Upload a ultra-high resolution image (e.g., 4000px wide). Verify that the processed file size shrinks significantly, and confirm through terminal logging that the generated canvas asset limits width/height strictly to 2048px.
[ ] Android Centering Success: Activate the image preview mode inside the Android Simulator. The image layout must align mathematically dead-center vertically, leaving symmetric padding bars on both the top header and bottom system navigation boundaries.
Binary file not shown.
+1 -1
View File
@@ -21,7 +21,7 @@
<meta name="twitter:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
<meta name="twitter:description" content="Khám phá những hành trình tuyệt vời và chia sẻ khoảnh khắc đẹp với cộng đồng du lịch." />
<meta name="twitter:image" content="https://yotrip.labz.io.vn/background.avif" />
<script type="module" crossorigin src="/assets/index-BuWSIZwN.js"></script>
<script type="module" crossorigin src="/assets/index-BDb3Pmxn.js"></script>
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-Cyuzqnbw.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-others-VIU5qAPG.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-editor-DOXkNaRS.js">
+26 -16
View File
@@ -823,24 +823,34 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
{isFullscreen && (
<div
className="fixed inset-0 z-[9999] bg-black/95 flex items-end sm:items-center justify-center cursor-zoom-out animate-in fade-in duration-200"
className="fixed inset-0 bg-black/95 backdrop-blur-sm flex flex-col items-center justify-center z-[999999] overflow-hidden animate-fade-in cursor-zoom-out"
onClick={() => setIsFullscreen(false)}
>
<button
onClick={() => setIsFullscreen(false)}
className="fixed top-6 right-6 p-3 bg-black/50 hover:bg-black/75 border border-white/10 rounded-full text-white transition-colors z-[10000]"
>
<X className="w-6 h-6" />
</button>
<img
src={photo.imageUrl}
alt="Fullscreen photo"
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
className={`max-w-full max-h-full sm:max-w-screen-md object-contain select-none animate-in zoom-in-95 duration-200 ${
!isLoggedIn ? 'pointer-events-none' : ''
}`}
/>
{/* Close Button Top Tracker Bar Container */}
<div className="absolute top-6 right-6 z-50">
<button
onClick={() => setIsFullscreen(false)}
className="p-3 bg-black/50 hover:bg-black/75 border border-white/10 rounded-full text-white transition-colors cursor-pointer"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Image wrapper frame context forcing clean alignment metrics */}
<div className="w-full h-full flex items-center justify-center p-4">
<img
src={photo.imageUrl}
alt="Fullscreen photo"
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
className={`max-w-full max-h-full sm:max-w-screen-md object-contain select-none animate-in zoom-in-95 duration-200 ${
!isLoggedIn ? 'pointer-events-none' : ''
}`}
style={{
transform: 'translate3d(0, 0, 0)'
}}
/>
</div>
</div>
)}
</div>
+11 -143
View File
@@ -13,7 +13,7 @@ import { AppDownloadBanner } from '../components/layout/AppDownloadBanner';
import { useNotification } from '@/hooks/useNotification';
import { processImageModeration } from '../hooks/useImageModeration';
import { useTranslation } from '../hooks/useTranslation';
import { compressImage } from '../utils/image';
import { processAndResizeImage } from '../utils/imageProcessor';
interface LandingPageProps {
onContinue?: () => void;
@@ -27,130 +27,6 @@ interface LandingPageProps {
onOpenNavigation?: (payload: any) => void;
}
// Helper to extract GPS location from EXIF tags in JPEG/JPG files
function getExifGps(file: File): Promise<{ latitude: number; longitude: number } | null> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const buffer = e.target?.result as ArrayBuffer;
const view = new DataView(buffer);
if (view.byteLength < 4) {
resolve(null);
return;
}
if (view.getUint16(0, false) !== 0xFFD8) {
resolve(null);
return;
}
let offset = 2;
const length = view.byteLength;
while (offset < length - 2) {
const marker = view.getUint16(offset, false);
if (marker === 0xFFE1) {
const app1Length = view.getUint16(offset + 2, false);
if (offset + 4 + app1Length > length) {
resolve(null);
return;
}
const exifHeader = view.getUint32(offset + 4, false);
if (exifHeader === 0x45786966) {
const tiffOffset = offset + 10;
const bigEndian = view.getUint16(tiffOffset, false) === 0x4D4D;
if (view.getUint16(tiffOffset + 2, bigEndian) !== 0x002A) {
resolve(null);
return;
}
const firstIFD = view.getUint32(tiffOffset + 4, bigEndian);
let ifdOffset = tiffOffset + firstIFD;
if (ifdOffset + 2 > length) {
resolve(null);
return;
}
const numEntries = view.getUint16(ifdOffset, bigEndian);
let gpsInfoOffset = 0;
for (let i = 0; i < numEntries; i++) {
const entryOffset = ifdOffset + 2 + i * 12;
if (entryOffset + 12 > length) break;
const tag = view.getUint16(entryOffset, bigEndian);
if (tag === 0x8825) {
gpsInfoOffset = view.getUint32(entryOffset + 8, bigEndian);
break;
}
}
if (gpsInfoOffset) {
const gpsIFDOffset = tiffOffset + gpsInfoOffset;
if (gpsIFDOffset + 2 > length) {
resolve(null);
return;
}
const numGpsEntries = view.getUint16(gpsIFDOffset, bigEndian);
let latRef = 'N';
let lonRef = 'E';
let latVal: number[] = [];
let lonVal: number[] = [];
for (let i = 0; i < numGpsEntries; i++) {
const entryOffset = gpsIFDOffset + 2 + i * 12;
if (entryOffset + 12 > length) break;
const tag = view.getUint16(entryOffset, bigEndian);
if (tag === 1) {
latRef = String.fromCharCode(view.getUint8(entryOffset + 8));
} else if (tag === 2) {
const offsetVal = view.getUint32(entryOffset + 8, bigEndian);
latVal = readRationalArray(view, tiffOffset + offsetVal, bigEndian, length);
} else if (tag === 3) {
lonRef = String.fromCharCode(view.getUint8(entryOffset + 8));
} else if (tag === 4) {
const offsetVal = view.getUint32(entryOffset + 8, bigEndian);
lonVal = readRationalArray(view, tiffOffset + offsetVal, bigEndian, length);
}
}
if (latVal.length === 3 && lonVal.length === 3) {
const latitude = convertDMSToDD(latVal[0], latVal[1], latVal[2], latRef);
const longitude = convertDMSToDD(lonVal[0], lonVal[1], lonVal[2], lonRef);
resolve({ latitude, longitude });
return;
}
}
}
break;
} else if (marker >= 0xFFD0 && marker <= 0xFFD9) {
offset += 2;
} else {
const blockLength = view.getUint16(offset + 2, false);
offset += 2 + blockLength;
}
}
resolve(null);
} catch (err) {
console.error('Error parsing EXIF:', err);
resolve(null);
}
};
reader.onerror = () => resolve(null);
reader.readAsArrayBuffer(file.slice(0, 128 * 1024));
});
}
function readRationalArray(view: DataView, offset: number, bigEndian: boolean, maxLen: number): number[] {
const values = [];
if (offset + 24 > maxLen) return [];
for (let i = 0; i < 3; i++) {
const num = view.getUint32(offset + i * 8, bigEndian);
const den = view.getUint32(offset + i * 8 + 4, bigEndian);
values.push(den === 0 ? 0 : num / den);
}
return values;
}
function convertDMSToDD(degrees: number, minutes: number, seconds: number, ref: string): number {
let dd = degrees + minutes / 60 + seconds / 3600;
if (ref === 'S' || ref === 'W') {
dd = -dd;
}
return dd;
}
export const LandingPage: React.FC<LandingPageProps> = ({
onGoToSignup,
onGoToMap,
@@ -318,30 +194,22 @@ export const LandingPage: React.FC<LandingPageProps> = ({
notify({ title: 'Đang xử lý...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
try {
// Nén ảnh trước
const compressedFile = await compressImage(file);
// Process image: Reads EXIF data and forces 2K resizing on device memory
const { file: processedFile, latitude: exifLat, longitude: exifLng } = await processAndResizeImage(file);
// 0. Kiểm duyệt ảnh
const moderationResult = await processImageModeration(compressedFile);
const moderationResult = await processImageModeration(processedFile);
if (moderationResult.blocked) {
notify({ title: 'Ảnh bị từ chối', message: `Ảnh ${file.name} chứa nội dung không phù hợp và bị chặn.`, type: 'error' });
return;
}
const processedFile = moderationResult.file;
const finalProcessedFile = moderationResult.file;
// Determine image upload coordinates based on priority checklist:
let finalLat: number | null = null;
let finalLng: number | null = null;
let finalLat: number | null = exifLat;
let finalLng: number | null = exifLng;
// 1. Read EXIF GPS metadata location from the uploaded image itself
try {
const exifLoc = await getExifGps(file);
if (exifLoc) {
finalLat = exifLoc.latitude;
finalLng = exifLoc.longitude;
console.log('[Upload Location] Priority 1: EXIF data coordinates found:', finalLat, finalLng);
}
} catch (e) {
console.error('[Upload Location] Error reading EXIF from file:', e);
if (finalLat !== null && finalLng !== null) {
console.log('[Upload Location] Priority 1: EXIF data coordinates found:', finalLat, finalLng);
}
// 2. Get current mobile/device GPS position of the user
@@ -396,11 +264,11 @@ export const LandingPage: React.FC<LandingPageProps> = ({
}
// Save file and resolved coordinates into state
setPendingPhotoFile(processedFile);
setPendingPhotoFile(finalProcessedFile);
setPendingPhotoLocation({ latitude: finalLat, longitude: finalLng });
// Tạo preview URL cho ảnh
const previewUrl = URL.createObjectURL(processedFile);
const previewUrl = URL.createObjectURL(finalProcessedFile);
setPhotoPreviewUrl(previewUrl);
setIsTagsModalOpen(true);
+205
View File
@@ -0,0 +1,205 @@
// Helper to extract GPS location from EXIF tags in JPEG/JPG files
function getExifGps(file: File): Promise<{ latitude: number; longitude: number } | null> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const buffer = e.target?.result as ArrayBuffer;
const view = new DataView(buffer);
if (view.byteLength < 4) {
resolve(null);
return;
}
if (view.getUint16(0, false) !== 0xFFD8) {
resolve(null);
return;
}
let offset = 2;
const length = view.byteLength;
while (offset < length - 2) {
const marker = view.getUint16(offset, false);
if (marker === 0xFFE1) {
const app1Length = view.getUint16(offset + 2, false);
if (offset + 4 + app1Length > length) {
resolve(null);
return;
}
const exifHeader = view.getUint32(offset + 4, false);
if (exifHeader === 0x45786966) {
const tiffOffset = offset + 10;
const bigEndian = view.getUint16(tiffOffset, false) === 0x4D4D;
if (view.getUint16(tiffOffset + 2, bigEndian) !== 0x002A) {
resolve(null);
return;
}
const firstIFD = view.getUint32(tiffOffset + 4, bigEndian);
let ifdOffset = tiffOffset + firstIFD;
if (ifdOffset + 2 > length) {
resolve(null);
return;
}
const numEntries = view.getUint16(ifdOffset, bigEndian);
let gpsInfoOffset = 0;
for (let i = 0; i < numEntries; i++) {
const entryOffset = ifdOffset + 2 + i * 12;
if (entryOffset + 12 > length) break;
const tag = view.getUint16(entryOffset, bigEndian);
if (tag === 0x8825) {
gpsInfoOffset = view.getUint32(entryOffset + 8, bigEndian);
break;
}
}
if (gpsInfoOffset) {
const gpsIFDOffset = tiffOffset + gpsInfoOffset;
if (gpsIFDOffset + 2 > length) {
resolve(null);
return;
}
const numGpsEntries = view.getUint16(gpsIFDOffset, bigEndian);
let latRef = 'N';
let lonRef = 'E';
let latVal: number[] = [];
let lonVal: number[] = [];
for (let i = 0; i < numGpsEntries; i++) {
const entryOffset = gpsIFDOffset + 2 + i * 12;
if (entryOffset + 12 > length) break;
const tag = view.getUint16(entryOffset, bigEndian);
if (tag === 1) {
latRef = String.fromCharCode(view.getUint8(entryOffset + 8));
} else if (tag === 2) {
const offsetVal = view.getUint32(entryOffset + 8, bigEndian);
latVal = readRationalArray(view, tiffOffset + offsetVal, bigEndian, length);
} else if (tag === 3) {
lonRef = String.fromCharCode(view.getUint8(entryOffset + 8));
} else if (tag === 4) {
const offsetVal = view.getUint32(entryOffset + 8, bigEndian);
lonVal = readRationalArray(view, tiffOffset + offsetVal, bigEndian, length);
}
}
if (latVal.length === 3 && lonVal.length === 3) {
const latitude = convertDMSToDD(latVal[0], latVal[1], latVal[2], latRef);
const longitude = convertDMSToDD(lonVal[0], lonVal[1], lonVal[2], lonRef);
resolve({ latitude, longitude });
return;
}
}
}
break;
} else if (marker >= 0xFFD0 && marker <= 0xFFD9) {
offset += 2;
} else {
const blockLength = view.getUint16(offset + 2, false);
offset += 2 + blockLength;
}
}
resolve(null);
} catch (err) {
console.error('Error parsing EXIF:', err);
resolve(null);
}
};
reader.onerror = () => resolve(null);
reader.readAsArrayBuffer(file.slice(0, 128 * 1024));
});
}
function readRationalArray(view: DataView, offset: number, bigEndian: boolean, maxLen: number): number[] {
const values = [];
if (offset + 24 > maxLen) return [];
for (let i = 0; i < 3; i++) {
const num = view.getUint32(offset + i * 8, bigEndian);
const den = view.getUint32(offset + i * 8 + 4, bigEndian);
values.push(den === 0 ? 0 : num / den);
}
return values;
}
function convertDMSToDD(degrees: number, minutes: number, seconds: number, ref: string): number {
let dd = degrees + minutes / 60 + seconds / 3600;
if (ref === 'S' || ref === 'W') {
dd = -dd;
}
return dd;
}
interface ProcessedImageResult {
file: File;
latitude: number | null;
longitude: number | null;
}
export const processAndResizeImage = async (file: File): Promise<ProcessedImageResult> => {
// 1. Read EXIF coordinates first from the original file
let latitude: number | null = null;
let longitude: number | null = null;
try {
const gps = await getExifGps(file);
if (gps) {
latitude = gps.latitude;
longitude = gps.longitude;
}
} catch (e) {
console.error('[imageProcessor] Failed to read EXIF GPS:', e);
}
// 2. Perform resizing to maximum 2048px on its longest edge
return new Promise((resolve) => {
if (!file.type.startsWith('image/')) {
return resolve({ file, latitude, longitude });
}
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target?.result as string;
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
const MAX_SIZE = 2048;
if (width > MAX_SIZE || height > MAX_SIZE) {
if (width > height) {
height = Math.round((height * MAX_SIZE) / width);
width = MAX_SIZE;
} else {
width = Math.round((width * MAX_SIZE) / height);
height = MAX_SIZE;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
return resolve({ file, latitude, longitude });
}
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
if (blob) {
const newName = file.name.replace(/\.[^/.]+$/, "") + ".jpg";
const resizedFile = new File([blob], newName, {
type: 'image/jpeg',
lastModified: Date.now()
});
resolve({
file: resizedFile,
latitude,
longitude
});
} else {
resolve({ file, latitude, longitude });
}
}, 'image/jpeg', 0.88); // 88% quality sweet-spot
};
img.onerror = () => {
resolve({ file, latitude, longitude });
};
};
reader.onerror = () => {
resolve({ file, latitude, longitude });
};
});
};