feat: backend /proxy endpoint for full-res RAW/HEIC display
Adds GET /photos/{id}/proxy that decodes RAW (rawpy), HEIC (pillow-heif),
and TIFF to a cached full-resolution WebP at /data/proxies/{id}.webp.
Web-safe formats (JPEG/PNG/WebP/GIF) pass through to the original to
avoid pointless transcoding. RAW failures fall back to extracting the
embedded JPEG preview. Mirrors the X-Accel-Redirect pattern from the
existing thumb endpoint.
Also fixes GET /photos/{id}/original to return the correct image/jpeg,
image/png, video/mp4, etc. content types instead of always serving
application/octet-stream, so <img> and <video> tags can render the
file inline rather than triggering a download.
Frontend: adds photos.getProxyUrl() helper in services/api.ts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -228,24 +228,162 @@ async def get_original(
|
||||
photo_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Serve original file for download"""
|
||||
"""Serve original file (download for RAW, inline for web-safe formats)"""
|
||||
result = await db.execute(
|
||||
select(Photo).where(Photo.id == photo_id)
|
||||
)
|
||||
photo = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if not photo:
|
||||
raise HTTPException(status_code=404, detail="Photo not found")
|
||||
|
||||
|
||||
if not os.path.exists(photo.filepath):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
|
||||
# Pick a media type the browser can render inline for web-safe formats
|
||||
# so the loupe view and <video> tags work without forcing a download.
|
||||
ext = Path(photo.filepath).suffix.lower()
|
||||
inline_types = {
|
||||
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif',
|
||||
'.mp4': 'video/mp4', '.mov': 'video/quicktime',
|
||||
'.webm': 'video/webm', '.mkv': 'video/x-matroska',
|
||||
}
|
||||
media_type = inline_types.get(ext, 'application/octet-stream')
|
||||
|
||||
return FileResponse(
|
||||
photo.filepath,
|
||||
filename=photo.filename,
|
||||
media_type='application/octet-stream'
|
||||
filename=photo.filename if media_type == 'application/octet-stream' else None,
|
||||
media_type=media_type,
|
||||
)
|
||||
|
||||
|
||||
# Extensions that the browser can decode natively. Anything else (RAW, HEIC,
|
||||
# TIFF) needs the /proxy endpoint to convert to WebP for display.
|
||||
_WEB_SAFE_DISPLAY_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
|
||||
|
||||
|
||||
def _generate_proxy_webp(src_path: str, dst_path: str) -> None:
|
||||
"""Decode src_path with the appropriate backend and write a full-res WebP
|
||||
to dst_path. Used by GET /photos/{id}/proxy for RAW/HEIC/TIFF display.
|
||||
|
||||
Conservative: catches per-format failures and falls back to extracting an
|
||||
embedded preview where possible (RAW), so a single broken file never
|
||||
crashes the request.
|
||||
"""
|
||||
from PIL import Image
|
||||
ext = Path(src_path).suffix.lower()
|
||||
|
||||
img = None
|
||||
|
||||
# RAW formats — decode via rawpy at full size
|
||||
raw_exts = {'.cr2', '.cr3', '.nef', '.nrw', '.arw', '.srf',
|
||||
'.raf', '.rw2', '.orf', '.srw', '.pef', '.rwl', '.dng'}
|
||||
if ext in raw_exts:
|
||||
try:
|
||||
import rawpy
|
||||
with rawpy.imread(src_path) as raw:
|
||||
rgb = raw.postprocess(use_camera_wb=True, no_auto_bright=False)
|
||||
img = Image.fromarray(rgb, 'RGB')
|
||||
except Exception as e:
|
||||
logger.warning(f"rawpy decode failed for {src_path}: {e}; trying embedded preview")
|
||||
try:
|
||||
import rawpy
|
||||
with rawpy.imread(src_path) as raw:
|
||||
thumb = raw.extract_thumb()
|
||||
if thumb.format == rawpy.ThumbFormat.JPEG:
|
||||
from io import BytesIO
|
||||
img = Image.open(BytesIO(thumb.data))
|
||||
except Exception as e2:
|
||||
logger.error(f"RAW preview extraction also failed for {src_path}: {e2}")
|
||||
raise HTTPException(status_code=415, detail="Unable to decode RAW file")
|
||||
|
||||
# HEIC/HEIF — pillow-heif registers a PIL plugin
|
||||
elif ext in {'.heic', '.heif'}:
|
||||
try:
|
||||
from pillow_heif import register_heif_opener
|
||||
register_heif_opener()
|
||||
img = Image.open(src_path)
|
||||
except Exception as e:
|
||||
logger.error(f"HEIC decode failed for {src_path}: {e}")
|
||||
raise HTTPException(status_code=415, detail="Unable to decode HEIC file")
|
||||
|
||||
# TIFF and any other PIL-supported format
|
||||
else:
|
||||
try:
|
||||
img = Image.open(src_path)
|
||||
except Exception as e:
|
||||
logger.error(f"PIL open failed for {src_path}: {e}")
|
||||
raise HTTPException(status_code=415, detail="Unable to decode image")
|
||||
|
||||
# Auto-rotate via EXIF
|
||||
try:
|
||||
from PIL import ImageOps
|
||||
img = ImageOps.exif_transpose(img)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if img.mode not in ('RGB', 'RGBA'):
|
||||
img = img.convert('RGB')
|
||||
|
||||
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
|
||||
img.save(dst_path, 'WEBP', quality=90, method=4)
|
||||
|
||||
|
||||
@router.get("/{photo_id}/proxy")
|
||||
async def get_proxy(
|
||||
photo_id: str,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Serve a full-resolution WebP proxy for non-web-safe formats (RAW, HEIC,
|
||||
TIFF) so the loupe view can display them inline. Web-safe formats are
|
||||
redirected to /original to avoid pointless transcoding.
|
||||
|
||||
Cached at /data/proxies/{photo_id}.webp; subsequent requests serve the
|
||||
cached file (with optional X-Accel-Redirect for production).
|
||||
"""
|
||||
result = await db.execute(select(Photo).where(Photo.id == photo_id))
|
||||
photo = result.scalar_one_or_none()
|
||||
|
||||
if not photo:
|
||||
raise HTTPException(status_code=404, detail="Photo not found")
|
||||
|
||||
if not os.path.exists(photo.filepath):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
ext = Path(photo.filepath).suffix.lower()
|
||||
|
||||
# Web-safe formats don't need a proxy — serve the original directly so the
|
||||
# browser uses its native decoder. Saves disk and CPU.
|
||||
if ext in _WEB_SAFE_DISPLAY_EXTS:
|
||||
return FileResponse(
|
||||
photo.filepath,
|
||||
media_type={
|
||||
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif',
|
||||
}[ext],
|
||||
)
|
||||
|
||||
proxy_dir = "/data/proxies"
|
||||
proxy_path = f"{proxy_dir}/{photo_id}.webp"
|
||||
|
||||
if not os.path.exists(proxy_path):
|
||||
try:
|
||||
_generate_proxy_webp(photo.filepath, proxy_path)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Proxy generation failed for {photo_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Proxy generation failed")
|
||||
|
||||
if os.environ.get('USE_X_ACCEL_REDIRECT'):
|
||||
response.headers['X-Accel-Redirect'] = f'/internal_proxies/{photo_id}.webp'
|
||||
response.headers['Content-Type'] = 'image/webp'
|
||||
return Response()
|
||||
|
||||
return FileResponse(proxy_path, media_type='image/webp')
|
||||
|
||||
@router.patch("/{photo_id}", response_model=PhotoResponse)
|
||||
async def update_photo(
|
||||
photo_id: str,
|
||||
|
||||
@@ -85,6 +85,12 @@ export const photos = {
|
||||
getOriginalUrl: (photoId: string) => {
|
||||
return `${API_BASE_URL}/photos/${photoId}/original`
|
||||
},
|
||||
|
||||
/** Full-resolution display URL. Backend serves the original for web-safe
|
||||
* formats and a transcoded WebP for RAW/HEIC/TIFF. */
|
||||
getProxyUrl: (photoId: string) => {
|
||||
return `${API_BASE_URL}/photos/${photoId}/proxy`
|
||||
},
|
||||
}
|
||||
|
||||
// Library API
|
||||
|
||||
Reference in New Issue
Block a user