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,7 +228,7 @@ async def get_original(
|
|||||||
photo_id: str,
|
photo_id: str,
|
||||||
db: AsyncSession = Depends(get_db)
|
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(
|
result = await db.execute(
|
||||||
select(Photo).where(Photo.id == photo_id)
|
select(Photo).where(Photo.id == photo_id)
|
||||||
)
|
)
|
||||||
@@ -240,12 +240,150 @@ async def get_original(
|
|||||||
if not os.path.exists(photo.filepath):
|
if not os.path.exists(photo.filepath):
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
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(
|
return FileResponse(
|
||||||
photo.filepath,
|
photo.filepath,
|
||||||
filename=photo.filename,
|
filename=photo.filename if media_type == 'application/octet-stream' else None,
|
||||||
media_type='application/octet-stream'
|
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)
|
@router.patch("/{photo_id}", response_model=PhotoResponse)
|
||||||
async def update_photo(
|
async def update_photo(
|
||||||
photo_id: str,
|
photo_id: str,
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ export const photos = {
|
|||||||
getOriginalUrl: (photoId: string) => {
|
getOriginalUrl: (photoId: string) => {
|
||||||
return `${API_BASE_URL}/photos/${photoId}/original`
|
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
|
// Library API
|
||||||
|
|||||||
Reference in New Issue
Block a user