diff --git a/ANDROID_FIX.md b/ANDROID_FIX.md deleted file mode 100644 index 1f31019..0000000 --- a/ANDROID_FIX.md +++ /dev/null @@ -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 */} -
- {/* Menu option items: Tạo tour, Hành trình của tôi, Thư viện ảnh... */} -
- -### 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([]); - -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. \ No newline at end of file diff --git a/PHOTO_FIX.md b/PHOTO_FIX.md new file mode 100644 index 0000000..b0514e7 --- /dev/null +++ b/PHOTO_FIX.md @@ -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 `` 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 => { + 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) => { + 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 */} +
+ +{/* ✅ AFTER: True viewport overlay centering bounding context */} +
+ {/* Close Button Top Tracker Bar Container */} +
+ +
+ + {/* Image wrapper frame context forcing clean alignment metrics */} +
+ YoTrip Preview +
+
+ +## 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. \ No newline at end of file diff --git a/backend/public/downloads/yotrip-latest.apk b/backend/public/downloads/yotrip-latest.apk index b24e6f9..e2d76a5 100644 Binary files a/backend/public/downloads/yotrip-latest.apk and b/backend/public/downloads/yotrip-latest.apk differ diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 4c018c4..9bf0764 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -21,7 +21,7 @@ - + diff --git a/frontend/src/components/PublicPhotoModal.tsx b/frontend/src/components/PublicPhotoModal.tsx index 69f67fd..0d23195 100644 --- a/frontend/src/components/PublicPhotoModal.tsx +++ b/frontend/src/components/PublicPhotoModal.tsx @@ -823,24 +823,34 @@ export const PublicPhotoModal: React.FC = ({ {isFullscreen && (
setIsFullscreen(false)} > - - Fullscreen photo { 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 */} +
+ +
+ + {/* Image wrapper frame context forcing clean alignment metrics */} +
+ Fullscreen photo { 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)' + }} + /> +
)}
diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx index 77162e1..ff721f3 100644 --- a/frontend/src/pages/LandingPage.tsx +++ b/frontend/src/pages/LandingPage.tsx @@ -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 = ({ onGoToSignup, onGoToMap, @@ -318,30 +194,22 @@ export const LandingPage: React.FC = ({ 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 = ({ } // 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); diff --git a/frontend/src/utils/imageProcessor.ts b/frontend/src/utils/imageProcessor.ts new file mode 100644 index 0000000..c98a2de --- /dev/null +++ b/frontend/src/utils/imageProcessor.ts @@ -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 => { + // 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 }); + }; + }); +};