From ffddeca268b9572179bbdf599d0080b01619e915 Mon Sep 17 00:00:00 2001 From: ilia Date: Thu, 9 Jul 2026 14:42:08 -0400 Subject: [PATCH 1/2] fix: address open triage issues (#21,#26,#45,#46,#47,#30-33) - #45: re-read local folder when recursive checkbox toggles - #47: clear Identify crop spinner when image is already cached - #46: search selected people by person_ids; AND for "First Last" - #21: enqueue network import without blocking API walk - #26: serve resized grid thumbnails for photos - #30/#31/#33: re-enable small-face filters in auto-match --- admin-frontend/src/api/photos.ts | 1 + admin-frontend/src/pages/Identify.tsx | 21 +++++ admin-frontend/src/pages/Scan.tsx | 42 ++++++++- admin-frontend/src/pages/Search.tsx | 40 +++----- backend/api/photos.py | 45 ++++++--- backend/services/face_service.py | 25 ++--- backend/services/search_service.py | 93 ++++++++++++------- .../app/api/photos/[id]/image/route.ts | 32 +++++++ viewer-frontend/components/PhotoGrid.tsx | 2 +- viewer-frontend/lib/photo-utils.ts | 11 ++- 10 files changed, 218 insertions(+), 94 deletions(-) diff --git a/admin-frontend/src/api/photos.ts b/admin-frontend/src/api/photos.ts index 1589da3..95ceb95 100644 --- a/admin-frontend/src/api/photos.ts +++ b/admin-frontend/src/api/photos.ts @@ -136,6 +136,7 @@ export const photosApi = { searchPhotos: async (params: { search_type: 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags' | 'processed' | 'unprocessed' | 'favorites' person_name?: string + person_ids?: string tag_names?: string match_all?: boolean date_from?: string diff --git a/admin-frontend/src/pages/Identify.tsx b/admin-frontend/src/pages/Identify.tsx index 140be10..7da2a24 100644 --- a/admin-frontend/src/pages/Identify.tsx +++ b/admin-frontend/src/pages/Identify.tsx @@ -600,6 +600,21 @@ export default function Identify() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentFace?.id, compareEnabled]) + // Clear loading when crop is already cached (browser may skip onLoad) — #47 + useEffect(() => { + if (!currentFace || !restorationCompleteRef.current) return + const baseUrl = apiClient.defaults.baseURL || '' + const url = `${baseUrl}/api/v1/faces/${currentFace.id}/crop` + const probe = new Image() + probe.onload = () => setImageLoading(false) + probe.onerror = () => setImageLoading(false) + probe.src = url + // If already complete from cache, onload may have fired sync before handlers + if (probe.complete && probe.naturalWidth > 0) { + setImageLoading(false) + } + }, [currentFace?.id]) + // Preload images for next/previous faces useEffect(() => { if (!currentFace || faces.length === 0) return @@ -1293,6 +1308,12 @@ export default function Identify() { className="max-w-full max-h-full object-contain pointer-events-none" crossOrigin="anonymous" loading="eager" + ref={(el) => { + // Cached images often skip onLoad — clear spinner if already complete (#47) + if (el && el.complete && el.naturalWidth > 0) { + setImageLoading(false) + } + }} onLoad={() => setImageLoading(false)} onError={(e) => { const target = e.target as HTMLImageElement diff --git a/admin-frontend/src/pages/Scan.tsx b/admin-frontend/src/pages/Scan.tsx index 77e9ad6..a319d87 100644 --- a/admin-frontend/src/pages/Scan.tsx +++ b/admin-frontend/src/pages/Scan.tsx @@ -76,6 +76,8 @@ export default function Scan() { } | null>(null) const [selectedFiles, setSelectedFiles] = useState([]) const fileInputRef = useRef(null) + // Keep directory handle so toggling "recursive" can re-read without re-picking the folder (#45) + const dirHandleRef = useRef(null) const [currentJob, setCurrentJob] = useState(null) const [jobProgress, setJobProgress] = useState(null) const [importResult, setImportResult] = useState<{ @@ -95,6 +97,36 @@ export default function Scan() { } }, []) + // Re-read local folder when recursive checkbox changes (#45) + useEffect(() => { + const handle = dirHandleRef.current + if (!handle || scanMode !== 'local' || isImporting) return + + let cancelled = false + ;(async () => { + try { + const files = await readDirectoryRecursive(handle, recursive) + if (cancelled) return + setSelectedFiles(files) + setImportResult(null) + if (files.length === 0) { + setError('No supported image or video files found with current recursive setting.') + } else { + setError(null) + } + } catch (err: any) { + if (!cancelled) { + setError(err?.message || 'Failed to re-read folder after recursive toggle') + } + } + })() + + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [recursive]) + const handleFolderBrowse = () => { setError(null) setShowFolderBrowser(true) @@ -215,10 +247,11 @@ export default function Scan() { // Show directory picker // @ts-ignore - File System Access API types may not be available const dirHandle = await window.showDirectoryPicker() + dirHandleRef.current = dirHandle const folderName = dirHandle.name setFolderPath(folderName) - // Read all files from the directory + // Read all files from the directory (respects current recursive checkbox) const files = await readDirectoryRecursive(dirHandle, recursive) if (files.length === 0) { @@ -246,6 +279,8 @@ export default function Scan() { } } else if (isWebkitDirectorySupported()) { // Fallback: Use webkitdirectory input (Firefox, older browsers) + // webkitdirectory is always recursive — checkbox cannot re-filter without re-pick + dirHandleRef.current = null fileInputRef.current?.click() } else { setError('Folder selection is not supported in your browser. Please use Chrome, Edge, Safari, or Firefox.') @@ -542,6 +577,11 @@ export default function Scan() { className="ml-2 block text-sm text-gray-700" > Scan subdirectories recursively + {scanMode === 'local' && dirHandleRef.current ? ( + (re-reads folder when toggled) + ) : scanMode === 'network' ? ( + (applies on next Start Scanning) + ) : null} diff --git a/admin-frontend/src/pages/Search.tsx b/admin-frontend/src/pages/Search.tsx index d48566d..07ef55f 100644 --- a/admin-frontend/src/pages/Search.tsx +++ b/admin-frontend/src/pages/Search.tsx @@ -239,27 +239,21 @@ export default function Search() { } if (currentSearchType === 'name') { - // Combine selected people names and free text input - // For selected people, use last name (most unique) or first+last if last name is empty - const selectedNames = selectedPeople.map(p => { - if (p.last_name) { - return p.last_name.trim() - } else if (p.first_name) { - return p.first_name.trim() - } - return formatPersonName(p).trim() - }) + // Selected people → exact person_ids (avoids last-name substring false hits, #46) + if (selectedPeople.length > 0) { + params.person_ids = selectedPeople.map(p => p.id).join(',') + } const freeText = inputValue.trim() - const allNames = [...selectedNames, freeText].filter(Boolean) - - if (allNames.length === 0) { + if (freeText) { + params.person_name = freeText + } + if (!params.person_ids && !params.person_name) { if (showValidationErrors) { alert('Please enter at least one name or select a person to search.') } setLoading(false) return } - params.person_name = allNames.join(', ') // Add tags filter if provided if (selectedTags.length > 0) { params.tag_names = selectedTags.join(', ') @@ -828,20 +822,12 @@ export default function Search() { } if (searchType === 'name') { - // Combine selected people names and free text input - // For selected people, use last name (most unique) or first+last if last name is empty - const selectedNames = selectedPeople.map(p => { - if (p.last_name) { - return p.last_name.trim() - } else if (p.first_name) { - return p.first_name.trim() - } - return formatPersonName(p).trim() - }) + if (selectedPeople.length > 0) { + baseParams.person_ids = selectedPeople.map(p => p.id).join(',') + } const freeText = inputValue.trim() - const allNames = [...selectedNames, freeText].filter(Boolean) - if (allNames.length > 0) { - baseParams.person_name = allNames.join(', ') + if (freeText) { + baseParams.person_name = freeText } // Add tags filter if provided if (selectedTags.length > 0) { diff --git a/backend/api/photos.py b/backend/api/photos.py index 9a3cfb3..92bcb4c 100644 --- a/backend/api/photos.py +++ b/backend/api/photos.py @@ -37,7 +37,6 @@ from backend.schemas.search import ( SearchPhotosResponse, ) from backend.services.photo_service import ( - find_photos_in_folder, import_photo_from_path, ) from backend.services.search_service import ( @@ -63,6 +62,10 @@ def search_photos( current_user: Annotated[dict, Depends(get_current_user)], search_type: str = Query("name", description="Search type: name, date, tags, no_faces, no_tags, processed, unprocessed, favorites"), person_name: Optional[str] = Query(None, description="Person name for name search"), + person_ids: Optional[str] = Query( + None, + description="Comma-separated person IDs for exact name search (preferred over substring when selecting people)", + ), tag_names: Optional[str] = Query(None, description="Comma-separated tag names for tag search"), match_all: bool = Query(False, description="Match all tags (for tag search)"), date_from: Optional[str] = Query(None, description="Date from (YYYY-MM-DD)"), @@ -111,13 +114,36 @@ def search_photos( tag_list = [t.strip() for t in tag_names.split(",") if t.strip()] if search_type == "name": - if not person_name: + parsed_person_ids: list[int] = [] + if person_ids: + for part in person_ids.split(","): + part = part.strip() + if not part: + continue + try: + parsed_person_ids.append(int(part)) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid person_ids value: {part}", + ) + if not person_name and not parsed_person_ids: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="person_name is required for name search", + detail="person_name or person_ids is required for name search", ) results, total = search_photos_by_name( - db, person_name, folder_path, media_type, df, dt, tag_list, match_all, page, page_size + db, + person_name or "", + folder_path, + media_type, + df, + dt, + tag_list, + match_all, + page, + page_size, + person_ids=parsed_person_ids or None, ) for photo, full_name in results: tags = get_photo_tags(db, photo.id) @@ -360,23 +386,20 @@ def import_photos( detail=f"Folder not found: {request.folder_path}", ) - # Estimate number of photos (quick scan) - estimated_photos = len(find_photos_in_folder(request.folder_path, request.recursive)) - - # Enqueue job - # Pass function as string path to avoid serialization issues + # Do NOT walk the tree on the API thread (#21) — that blocked SharePoint/SMB + # imports for minutes with no progress. Counting happens inside the RQ job. job = queue.enqueue( "backend.services.tasks.import_photos_task", request.folder_path, request.recursive, - job_timeout="1h", # Allow up to 1 hour for large imports + job_timeout="2h", # Network shares can be slow ) return PhotoImportResponse( job_id=job.id, message=f"Photo import job queued for {request.folder_path}", folder_path=request.folder_path, - estimated_photos=estimated_photos, + estimated_photos=None, ) diff --git a/backend/services/face_service.py b/backend/services/face_service.py index 334c409..86bc005 100644 --- a/backend/services/face_service.py +++ b/backend/services/face_service.py @@ -2337,27 +2337,22 @@ def find_auto_match_matches( for person_id, reference_face, person_name in person_faces_list: reference_face_id = reference_face.id - # TEMPORARILY DISABLED: Check if reference face is too small (exclude from auto-match) - # reference_photo = db.query(Photo).filter(Photo.id == reference_face.photo_id).first() - # if reference_photo: - # ref_size_ratio = _calculate_face_size_ratio(reference_face, reference_photo) - # if ref_size_ratio < MIN_AUTO_MATCH_FACE_SIZE_RATIO: - # # Skip this person - reference face is too small - # continue - + # Skip people whose reference face is too small (reduces false positives, #30/#31/#33) + reference_photo = db.query(Photo).filter(Photo.id == reference_face.photo_id).first() + if reference_photo: + ref_size_ratio = _calculate_face_size_ratio(reference_face, reference_photo) + if ref_size_ratio < MIN_AUTO_MATCH_FACE_SIZE_RATIO: + continue + # Use find_similar_faces which matches desktop _get_filtered_similar_faces logic - # Desktop: similar_faces = self.face_processor._get_filtered_similar_faces( - # reference_face_id, tolerance, include_same_photo=False, face_status=None) - # This filters by: person_id is None (unidentified), confidence >= 50% (increased from 40%), sorts by distance - # Auto-match always excludes excluded faces - # TEMPORARILY DISABLED: filter_small_faces=True to exclude small match faces + # Filters: person_id is None, confidence floor, exclude small faces, sort by distance similar_faces_with_debug = find_similar_faces( db, reference_face_id, tolerance=tolerance, filter_frontal_only=filter_frontal_only, include_excluded=False, # Auto-match always excludes excluded faces - filter_small_faces=False, # TEMPORARILY DISABLED: Exclude small faces from auto-match + filter_small_faces=True, # Exclude tiny faces that inflate false matches min_face_size_ratio=MIN_AUTO_MATCH_FACE_SIZE_RATIO, - use_distance_based_thresholds=use_distance_based_thresholds # Use distance-based thresholds if enabled + use_distance_based_thresholds=use_distance_based_thresholds ) # Strip debug_info for internal use similar_faces = [(f, dist, conf) for f, dist, conf, _ in similar_faces_with_debug] diff --git a/backend/services/search_service.py b/backend/services/search_service.py index 99bd520..a014287 100644 --- a/backend/services/search_service.py +++ b/backend/services/search_service.py @@ -22,57 +22,78 @@ def search_photos_by_name( match_all: bool = False, page: int = 1, page_size: int = 50, + person_ids: Optional[List[int]] = None, ) -> Tuple[List[Tuple[Photo, str]], int]: - """Search photos by person name(s) (partial, case-insensitive). - + """Search photos by person name(s) and/or exact person IDs. + Supports multiple names separated by commas (OR logic - photos matching any name). - - Matches desktop behavior exactly: + When person_ids is provided, those people are included exactly (no substring match). + + Matches desktop behavior for free-text: - Searches first_name, last_name, middle_name, maiden_name - Returns (photo, full_name) tuples - - Filters by folder_path if provided - - Filters by media_type if provided ("image" or "video", None/"all" for all) - - Multiple names: comma-separated, searches for photos with ANY matching person """ search_name = (person_name or "").strip() - if not search_name: - return [], 0 - + explicit_ids = [int(pid) for pid in (person_ids or []) if pid is not None] + # Split by comma and clean up names - search_names = [name.strip().lower() for name in search_name.split(',') if name.strip()] - if not search_names: + search_names = [name.strip().lower() for name in search_name.split(',') if name.strip()] if search_name else [] + + if not search_names and not explicit_ids: return [], 0 - - # Build OR conditions for each search name - name_conditions = [] - for search_name_lower in search_names: - name_conditions.append( - or_( - func.lower(Person.first_name).contains(search_name_lower), - func.lower(Person.last_name).contains(search_name_lower), - func.lower(Person.middle_name).contains(search_name_lower), - func.lower(Person.maiden_name).contains(search_name_lower), - ) + + matching_people: List[Person] = [] + + if explicit_ids: + matching_people.extend( + db.query(Person).filter(Person.id.in_(explicit_ids)).all() ) - - # Find matching people (any of the search names) - matching_people = ( - db.query(Person) - .filter(or_(*name_conditions)) - .all() - ) - - if not matching_people: + + if search_names: + # Build OR conditions for each search name + name_conditions = [] + for search_name_lower in search_names: + # Multi-word "First Last" → require first AND last (reduces false hits, #46) + parts = [p for p in search_name_lower.split() if p] + if len(parts) >= 2: + first, last = parts[0], parts[-1] + name_conditions.append( + (func.lower(Person.first_name).contains(first)) + & (func.lower(Person.last_name).contains(last)) + ) + else: + name_conditions.append( + or_( + func.lower(Person.first_name).contains(search_name_lower), + func.lower(Person.last_name).contains(search_name_lower), + func.lower(Person.middle_name).contains(search_name_lower), + func.lower(Person.maiden_name).contains(search_name_lower), + ) + ) + + matching_people.extend( + db.query(Person).filter(or_(*name_conditions)).all() + ) + + # Deduplicate people by id + seen = set() + unique_people: List[Person] = [] + for p in matching_people: + if p.id not in seen: + seen.add(p.id) + unique_people.append(p) + + if not unique_people: return [], 0 - - person_ids = [p.id for p in matching_people] - + + person_ids_resolved = [p.id for p in unique_people] + # Query photos with faces linked to matching people query = ( db.query(Photo, Person) .join(Face, Photo.id == Face.photo_id) .join(Person, Face.person_id == Person.id) - .filter(Face.person_id.in_(person_ids)) + .filter(Face.person_id.in_(person_ids_resolved)) .distinct() ) diff --git a/viewer-frontend/app/api/photos/[id]/image/route.ts b/viewer-frontend/app/api/photos/[id]/image/route.ts index c3db63a..b0e32b0 100644 --- a/viewer-frontend/app/api/photos/[id]/image/route.ts +++ b/viewer-frontend/app/api/photos/[id]/image/route.ts @@ -260,6 +260,38 @@ export async function GET( }, }); + // Grid thumbnails: resize photos (videos handled above) — #26 + if (thumbnail && sharp) { + try { + let pipeline = sharp(imageBuffer).rotate().resize({ + width: 480, + height: 480, + fit: 'inside', + withoutEnlargement: true, + }); + if (watermark) { + const metadata = await pipeline.metadata(); + const baseWidth = metadata.width ?? 480; + const baseHeight = metadata.height ?? 480; + const overlayBuffer = await getWatermarkOverlay(baseWidth, baseHeight); + pipeline = sharp(imageBuffer) + .rotate() + .resize({ width: 480, height: 480, fit: 'inside', withoutEnlargement: true }) + .composite([{ input: overlayBuffer, gravity: 'center', blend: 'hard-light' }]); + } + const result = await pipeline.jpeg({ quality: 75 }).toBuffer(); + return new NextResponse(result as unknown as BodyInit, { + headers: { + 'Content-Type': 'image/jpeg', + 'Cache-Control': 'public, max-age=86400', + }, + }); + } catch (error) { + console.error('Error generating image thumbnail:', error); + // fall through to original / watermarked full image + } + } + if (!watermark) { return createOriginalResponse(); } diff --git a/viewer-frontend/components/PhotoGrid.tsx b/viewer-frontend/components/PhotoGrid.tsx index 7c03270..29bade7 100644 --- a/viewer-frontend/components/PhotoGrid.tsx +++ b/viewer-frontend/components/PhotoGrid.tsx @@ -620,7 +620,7 @@ export function PhotoGrid({ onMouseLeave={() => setHoveredFace(null)} > {photo.filename} Date: Thu, 9 Jul 2026 14:54:08 -0400 Subject: [PATCH 2/2] perf: disk-cache grid thumbnails and document 2026-07-09 fixes Cache resized thumbs under .cache/photo-thumbnails (360px) so repeat grid loads skip Sharp. Document ticket/infra fixes; drop unused SVGs. --- docs/FIXES_2026-07-09.md | 51 +++++++++ .../app/api/photos/[id]/image/route.ts | 53 +++++++-- viewer-frontend/components/PhotoGrid.tsx | 2 +- viewer-frontend/public/file.svg | 1 - viewer-frontend/public/globe.svg | 1 - viewer-frontend/public/jam-watermark.svg | 27 ----- viewer-frontend/public/next.svg | 1 - viewer-frontend/public/vercel.svg | 1 - viewer-frontend/public/window.svg | 1 - viewer-frontend/test-image-detection.ts | 102 ------------------ 10 files changed, 96 insertions(+), 144 deletions(-) create mode 100644 docs/FIXES_2026-07-09.md delete mode 100644 viewer-frontend/public/file.svg delete mode 100644 viewer-frontend/public/globe.svg delete mode 100644 viewer-frontend/public/jam-watermark.svg delete mode 100644 viewer-frontend/public/next.svg delete mode 100644 viewer-frontend/public/vercel.svg delete mode 100644 viewer-frontend/public/window.svg delete mode 100644 viewer-frontend/test-image-detection.ts diff --git a/docs/FIXES_2026-07-09.md b/docs/FIXES_2026-07-09.md new file mode 100644 index 0000000..7aa255d --- /dev/null +++ b/docs/FIXES_2026-07-09.md @@ -0,0 +1,51 @@ +# PunimTag fixes — 2026-07-09 + +Branch: `fix/open-issues-triage` · PR: https://git.levkin.ca/ilia/punimtag/pulls/51 +Dev URLs: https://punimtagdev.levkin.ca · https://punimtagadmindev.levkin.ca +(`punimtag.levkin.ca` is **not** live — no DNS.) + +## Infra (homelab) + +| Item | Detail | +|------|--------| +| Outage | App `.env` pointed at dead Postgres `10.0.10.181` | +| Fix | Retarget to live `postgresQA` **`10.0.10.179`** (pve201 VM 109) | +| Hosts | LXC **9101** `punimTagFE-dev` @ `10.0.10.121` | + +Ansible inventory/docs updated in the ansible repo (`main.yml`, `vault.example.yml`, `host-list.md`). + +## Ticket fixes (code) + +| Issue | Status | Change | +|-------|--------|--------| +| **#45** Recursive scan checkbox | Closed | Keep directory handle; re-read files when checkbox toggles (`Scan.tsx`) | +| **#46** Person search wrong people | Closed | Selected people use exact `person_ids`; free-text `"First Last"` uses AND (`Search.tsx`, `search_service.py`) | +| **#47** Identify Next/Prev blank image | Closed | Clear loading when crop is already browser-cached (`Identify.tsx`) | +| **#21** SharePoint/network scan hang | Open (needs QA) | Stop blocking API on full folder walk before enqueue; 2h job timeout (`photos.py`) | +| **#26** Slow main-page images | Open (needs QA) | Grid requests `?thumbnail=true`; API resizes + **disk-caches** thumbs under `.cache/photo-thumbnails/` | +| **#30/#31/#33** Auto-match false positives | Open (needs QA) | Re-enable small-face / tiny-reference filters (`face_service.py`) | + +## Still blocked on client + +| Issue | Need | +|-------|------| +| **#44** JRCC watermark | SVG/PNG + placement/opacity | +| **#43** Tag without account | Product rules (approval, PII, ToS) | +| **#21** path confirm | Exact SharePoint/network path Noam used + mount status on LXC | + +## Performance notes (#26) + +**Why it felt slow (before deploy):** grid loaded **full-resolution** photos (and watermarked them for guests). Thumbnail resize existed only on the PR branch until deploy. + +**What we do now:** +1. Grid always requests `?thumbnail=true` +2. Server resizes to max **360px** JPEG (~quality 68) +3. Writes result to `.cache/photo-thumbnails/` keyed by photo id + mtime (first hit slow, later hits fast) +4. Long browser cache (`max-age=604800`) +5. Fewer `priority` images above the fold (4 instead of 9) + +**Further options (not done):** pre-generate thumbs at Process time; CDN; reduce SSR face payload on `page.tsx`. + +## Cleanup + +Removed unused viewer assets: default Next SVGs, unused `jam-watermark.svg`, `test-image-detection.ts`. diff --git a/viewer-frontend/app/api/photos/[id]/image/route.ts b/viewer-frontend/app/api/photos/[id]/image/route.ts index b0e32b0..e399330 100644 --- a/viewer-frontend/app/api/photos/[id]/image/route.ts +++ b/viewer-frontend/app/api/photos/[id]/image/route.ts @@ -3,7 +3,7 @@ import { fastApiV1Url } from '@/lib/server/fastapi-backend'; import { prisma } from '@/lib/db'; import { readFile } from 'fs/promises'; import { createReadStream } from 'fs'; -import { existsSync, statSync } from 'fs'; +import { existsSync, statSync, mkdirSync, writeFileSync, readFileSync } from 'fs'; import path from 'path'; // Conditionally import sharp - handle case where libvips is not installed @@ -18,6 +18,21 @@ try { const WATERMARK_BUCKET_SIZE = 200; const watermarkCache = new Map(); +const THUMB_CACHE_DIR = path.join(process.cwd(), '.cache', 'photo-thumbnails'); +const THUMB_MAX = 360; // grid cells are small; keep bytes down (#26) + +function ensureThumbCacheDir() { + if (!existsSync(THUMB_CACHE_DIR)) { + mkdirSync(THUMB_CACHE_DIR, { recursive: true }); + } +} + +function thumbCachePath(photoId: number, watermark: boolean, mtimeMs: number) { + const tag = watermark ? 'wm' : 'plain'; + return path.join(THUMB_CACHE_DIR, `${photoId}-${tag}-${THUMB_MAX}-${mtimeMs}.jpg`); +} + + async function getWatermarkOverlay(baseWidth: number, baseHeight: number) { if (!sharp) { throw new Error('Sharp library not available'); @@ -260,30 +275,50 @@ export async function GET( }, }); - // Grid thumbnails: resize photos (videos handled above) — #26 + // Grid thumbnails: resize + disk cache (videos handled above) — #26 if (thumbnail && sharp) { try { + ensureThumbCacheDir(); + const mtimeMs = statSync(filePath).mtimeMs; + const cacheFile = thumbCachePath(photoId, watermark, mtimeMs); + if (existsSync(cacheFile)) { + const cached = readFileSync(cacheFile); + return new NextResponse(cached, { + headers: { + 'Content-Type': 'image/jpeg', + 'Cache-Control': 'public, max-age=604800, immutable', + 'X-Thumb-Cache': 'HIT', + }, + }); + } + let pipeline = sharp(imageBuffer).rotate().resize({ - width: 480, - height: 480, + width: THUMB_MAX, + height: THUMB_MAX, fit: 'inside', withoutEnlargement: true, }); if (watermark) { const metadata = await pipeline.metadata(); - const baseWidth = metadata.width ?? 480; - const baseHeight = metadata.height ?? 480; + const baseWidth = metadata.width ?? THUMB_MAX; + const baseHeight = metadata.height ?? THUMB_MAX; const overlayBuffer = await getWatermarkOverlay(baseWidth, baseHeight); pipeline = sharp(imageBuffer) .rotate() - .resize({ width: 480, height: 480, fit: 'inside', withoutEnlargement: true }) + .resize({ width: THUMB_MAX, height: THUMB_MAX, fit: 'inside', withoutEnlargement: true }) .composite([{ input: overlayBuffer, gravity: 'center', blend: 'hard-light' }]); } - const result = await pipeline.jpeg({ quality: 75 }).toBuffer(); + const result = await pipeline.jpeg({ quality: 68, mozjpeg: true }).toBuffer(); + try { + writeFileSync(cacheFile, result); + } catch (e) { + console.warn('thumb cache write failed', e); + } return new NextResponse(result as unknown as BodyInit, { headers: { 'Content-Type': 'image/jpeg', - 'Cache-Control': 'public, max-age=86400', + 'Cache-Control': 'public, max-age=604800, immutable', + 'X-Thumb-Cache': 'MISS', }, }); } catch (error) { diff --git a/viewer-frontend/components/PhotoGrid.tsx b/viewer-frontend/components/PhotoGrid.tsx index 29bade7..cf15b37 100644 --- a/viewer-frontend/components/PhotoGrid.tsx +++ b/viewer-frontend/components/PhotoGrid.tsx @@ -625,7 +625,7 @@ export function PhotoGrid({ fill className="object-contain bg-black/5 transition-transform duration-300 group-hover:scale-105" sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw" - priority={index < 9} + priority={index < 4} unoptimized={!isUrl(photo.path)} onLoad={(e) => !isVideoPhoto && handleImageLoad(photo.id, e.currentTarget)} /> diff --git a/viewer-frontend/public/file.svg b/viewer-frontend/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/viewer-frontend/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/viewer-frontend/public/globe.svg b/viewer-frontend/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/viewer-frontend/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/viewer-frontend/public/jam-watermark.svg b/viewer-frontend/public/jam-watermark.svg deleted file mode 100644 index 710ba44..0000000 --- a/viewer-frontend/public/jam-watermark.svg +++ /dev/null @@ -1,27 +0,0 @@ - - JAM Jewish and Modern watermark - Stylized JAM logo with Star of David and the words Jewish and Modern - - - - - - - - - J - M - - - - Jewish - and Modern - - - - - - - - - diff --git a/viewer-frontend/public/next.svg b/viewer-frontend/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/viewer-frontend/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/viewer-frontend/public/vercel.svg b/viewer-frontend/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/viewer-frontend/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/viewer-frontend/public/window.svg b/viewer-frontend/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/viewer-frontend/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/viewer-frontend/test-image-detection.ts b/viewer-frontend/test-image-detection.ts deleted file mode 100644 index 8c223c4..0000000 --- a/viewer-frontend/test-image-detection.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Test script to verify image source detection logic - * Run with: npx tsx test-image-detection.ts - */ - -/** - * Determines if a path is a URL (http/https) or a file system path - */ -function isUrl(path: string): boolean { - return path.startsWith('http://') || path.startsWith('https://'); -} - -/** - * Gets the appropriate image source URL - */ -function getImageSrc(photoId: number, path: string): string { - if (isUrl(path)) { - return path; // Direct access - } else { - return `/api/photos/${photoId}/image`; // API proxy - } -} - -// Test cases -const testCases = [ - { - id: 1, - path: 'https://picsum.photos/800/600', - expected: 'direct', - description: 'HTTPS URL (SharePoint, CDN)', - }, - { - id: 2, - path: 'http://example.com/image.jpg', - expected: 'direct', - description: 'HTTP URL', - }, - { - id: 3, - path: '/path/to/photos/image.jpg', - expected: 'proxy', - description: 'Unix file system path', - }, - { - id: 4, - path: 'C:\\Photos\\image.jpg', - expected: 'proxy', - description: 'Windows file system path', - }, - { - id: 5, - path: 'https://yourcompany.sharepoint.com/sites/Photos/image.jpg', - expected: 'direct', - description: 'SharePoint Online URL', - }, - { - id: 6, - path: 'https://sharepoint.company.com/sites/Photos/image.jpg', - expected: 'direct', - description: 'SharePoint Server URL', - }, -]; - -console.log('🧪 Testing Image Source Detection Logic\n'); -console.log('=' .repeat(60)); - -let passed = 0; -let failed = 0; - -testCases.forEach((testCase) => { - const result = getImageSrc(testCase.id, testCase.path); - const isDirect = isUrl(testCase.path); - const actual = isDirect ? 'direct' : 'proxy'; - const success = actual === testCase.expected; - - if (success) { - passed++; - console.log(`✅ Test ${testCase.id}: PASSED`); - } else { - failed++; - console.log(`❌ Test ${testCase.id}: FAILED`); - } - - console.log(` Description: ${testCase.description}`); - console.log(` Path: ${testCase.path}`); - console.log(` Detected as: ${actual} (expected: ${testCase.expected})`); - console.log(` Image src: ${result}`); - console.log(''); -}); - -console.log('=' .repeat(60)); -console.log(`Results: ${passed} passed, ${failed} failed`); - -if (failed === 0) { - console.log('🎉 All tests passed!'); - process.exit(0); -} else { - console.log('⚠️ Some tests failed'); - process.exit(1); -} - - -- 2.49.1