Fix iOS hang when selecting many photos
Root cause: FileReader.readAsDataURL() loads the entire file into memory as a base64 string. With 50+ iPhone photos (3-12 MB each), this exceeds iOS Safari's per-tab memory limit and freezes the tab. Changes: - Replace readAsDataURL with URL.createObjectURL(file) — zero-copy file reference, no memory bloat (static/index.html) - Reduce batch size from 4 to 2 — gentler on memory-constrained devices - Add URL.revokeObjectURL() on reset — prevent blob URL leaks - Add HEIC/HEIF support to server.py — iPhone format compatibility Closes #1
@@ -6,6 +6,14 @@ from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from PIL import Image as PILImage
|
from PIL import Image as PILImage
|
||||||
|
|
||||||
|
# Optional HEIC/HEIF support for iPhone photos
|
||||||
|
try:
|
||||||
|
from pillow_heif import register_heif_opener
|
||||||
|
register_heif_opener()
|
||||||
|
HAS_HEIF = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_HEIF = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pytesseract
|
import pytesseract
|
||||||
HAS_TESSERACT = True
|
HAS_TESSERACT = True
|
||||||
@@ -148,7 +156,7 @@ async def analyze_photo(file: UploadFile = File(...)):
|
|||||||
file_size = len(contents)
|
file_size = len(contents)
|
||||||
|
|
||||||
ext = Path(file.filename or "photo.jpg").suffix.lower()
|
ext = Path(file.filename or "photo.jpg").suffix.lower()
|
||||||
if ext not in {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif", ".dng"}:
|
if ext not in {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif", ".dng", ".heic", ".heif"}:
|
||||||
ext = ".jpg"
|
ext = ".jpg"
|
||||||
fname = f"{uuid.uuid4().hex}{ext}"
|
fname = f"{uuid.uuid4().hex}{ext}"
|
||||||
(UPLOADS / fname).write_bytes(contents)
|
(UPLOADS / fname).write_bytes(contents)
|
||||||
@@ -194,7 +202,7 @@ async def bulk_process(files: list[UploadFile] = File(...)):
|
|||||||
|
|
||||||
# Save
|
# Save
|
||||||
ext = Path(file.filename or "photo.jpg").suffix.lower()
|
ext = Path(file.filename or "photo.jpg").suffix.lower()
|
||||||
if ext not in {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif", ".dng"}:
|
if ext not in {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif", ".dng", ".heic", ".heif"}:
|
||||||
ext = ".jpg"
|
ext = ".jpg"
|
||||||
fname = f"{uuid.uuid4().hex}{ext}"
|
fname = f"{uuid.uuid4().hex}{ext}"
|
||||||
(UPLOADS / fname).write_bytes(contents)
|
(UPLOADS / fname).write_bytes(contents)
|
||||||
|
|||||||
@@ -304,9 +304,9 @@ document.getElementById('fileInput').addEventListener('change', async function(e
|
|||||||
'</div>'
|
'</div>'
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
// Scan all in parallel (limited to 4 at a time to avoid memory issues)
|
// Scan in small batches — iOS Safari can't handle many concurrent file reads
|
||||||
const results = [];
|
const results = [];
|
||||||
const batchSize = 4;
|
const batchSize = 2;
|
||||||
for (let i = 0; i < files.length; i += batchSize) {
|
for (let i = 0; i < files.length; i += batchSize) {
|
||||||
const batch = files.slice(i, i + batchSize);
|
const batch = files.slice(i, i + batchSize);
|
||||||
const batchResults = await Promise.all(batch.map(f => scanPhoto(f)));
|
const batchResults = await Promise.all(batch.map(f => scanPhoto(f)));
|
||||||
@@ -321,12 +321,9 @@ document.getElementById('fileInput').addEventListener('change', async function(e
|
|||||||
async function scanPhoto(file) {
|
async function scanPhoto(file) {
|
||||||
const result = { file, exif: null, hasGps: false, lat: null, lng: null, thumb: null };
|
const result = { file, exif: null, hasGps: false, lat: null, lng: null, thumb: null };
|
||||||
|
|
||||||
// Generate thumbnail
|
// Use object URL instead of readAsDataURL — avoids loading the entire file
|
||||||
result.thumb = await new Promise(resolve => {
|
// into memory as a base64 string (the main cause of iOS hangs with many photos)
|
||||||
const reader = new FileReader();
|
result.thumb = URL.createObjectURL(file);
|
||||||
reader.onload = e => resolve(e.target.result);
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Read EXIF
|
// Read EXIF
|
||||||
try {
|
try {
|
||||||
@@ -726,6 +723,12 @@ async function pushGpsToAsset(idx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetAll() {
|
function resetAll() {
|
||||||
|
// Revoke all blob URLs to prevent memory leaks
|
||||||
|
allPhotos.forEach(p => {
|
||||||
|
if (p.thumb && p.thumb.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(p.thumb);
|
||||||
|
}
|
||||||
|
});
|
||||||
allPhotos = [];
|
allPhotos = [];
|
||||||
selectedIdx = -1;
|
selectedIdx = -1;
|
||||||
currentFilter = 'all';
|
currentFilter = 'all';
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 2.5 MiB |