Compress media views without touching stored originals - #37
Conversation
- Add on-demand, cached server-side resize endpoint (GET /api/media/{id}/display)
using sharp: generates a 1920px/q82 JPEG the first time a photo is viewed
full-size, caches it via a new displayBlobPath/displayUrl on MediaEntity, and
reuses it on every subsequent view. Original blobUrl is never modified, so
full-resolution downloads stay intact and hundreds of already-uploaded
photos are auto-backfilled the first time each is opened, with no migration.
- MediaLightbox fetches displayUrl for photos (falling back to blobUrl while
pending/on error) and gets a dedicated "download full size" button that
always links the original blobUrl.
- Fix PhotoStream grid to actually use thumbUrl (200px client thumbnail)
instead of unconditionally rendering the full-size blobUrl for photos and
video previews - this was the real cause of ~500MB page loads.
- Cap the "watch the trip" live grid to the 12 most recent posts so it stays
a quick teaser instead of growing page-length as more photos are uploaded;
map pins are unaffected since they use the full useMediaPosts() result.
- Sort /api/media and /api/media/mine by capturedAt (descending) instead of
relying on upload-time RowKey order, so photos uploaded late still appear
in the right place. media.ts now scans the full partition (capped by a
defensive SCAN_CAP) and sorts in memory before slicing to the page limit.
- mediaItem.ts DELETE also removes the cached displayBlobPath blob.
All changes are additive (new optional entity fields, new endpoint, new
blobs); no existing data is modified or removed.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds a lazy, on-read “display-sized” photo path to reduce bandwidth while preserving original uploads, and fixes frontend usage of existing thumbnails so the live grid doesn’t pull full-resolution media unnecessarily.
Changes:
- Added
GET /api/media/{id}/displayto generate/cache a 1920px JPEG “display” copy for photos (usingsharp) and return its URL. - Updated the frontend lightbox to prefer
displayUrlfor photo viewing while providing an explicit download link that always uses the originalblobUrl. - Fixed
PhotoStreamto use thumbnails for photo/video previews, capped the preview grid to 12 items, and updated API listing to sort bycapturedAtdesc.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/hooks/useMediaPosts.ts | Extends client media model with optional displayUrl. |
| src/components/PhotoStream.tsx | Uses thumbUrl for previews and caps the grid to 12 recent posts. |
| src/components/MediaLightbox.tsx | Fetches/uses displayUrl for photo viewing; adds a full-resolution download button. |
| api/src/mediaTable.ts | Adds optional displayBlobPath/displayUrl fields to the media entity schema. |
| api/src/mediaBlob.ts | Adds generateDisplayBlobPath() helper for derived “display” JPEG blobs. |
| api/src/functions/mediaMine.ts | Returns displayUrl and sorts by capturedAt for “mine” listing. |
| api/src/functions/mediaItem.ts | Deletes cached display blob (if present) when deleting a post. |
| api/src/functions/mediaDisplay.ts | New endpoint to lazily generate/cache a display-sized photo. |
| api/src/functions/media.ts | Includes displayUrl and sorts results by capturedAt desc with a scan cap. |
| api/package.json | Adds sharp dependency for server-side image processing. |
| api/package-lock.json | Locks sharp and records Node engine requirements for API deps. |
Files not reviewed (1)
- api/package-lock.json: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -41,6 +42,10 @@ export async function mediaMine(request: HttpRequest, context: InvocationContext | |||
| if (posts.length >= MAX_LIMIT) break; | |||
There was a problem hiding this comment.
Good catch, fixed - now collecting the full filtered set (capped by a new SCAN_CAP=5000 safety ceiling) and sorting by capturedAt before slicing to MAX_LIMIT, same as media.ts.
| } catch (error) { | ||
| // Best-effort, same philosophy as client-side thumbnail generation - | ||
| // e.g. a HEIC original that this sharp build can't decode. Never fail | ||
| // the view over it, just fall back to the full-size original. | ||
| context.warn('Falling back to blobUrl - display generation failed for ' + id, error); | ||
| return { status: 200, jsonBody: { displayUrl: entity.blobUrl }, headers: { 'Cache-Control': 'no-store' } }; | ||
| } |
There was a problem hiding this comment.
Fixed - the failure path now persists displayUrl=blobUrl via the same Merge update as the success path, so a permanently-failing original (e.g. HEIC) only pays the sharp() decode attempt once instead of on every view.
| "engines": { | ||
| "node": ">=22" | ||
| } |
There was a problem hiding this comment.
This engines>=22 requirement predates this PR - it's already declared in api/package.json on main, and comes from @azure/storage-blob's own dependency tree, not sharp (sharp 0.34.5's own binaries only need Node 18.17/20.3/21+). Running npm install just synced the lockfile to match. The API is deployed via a separate Oryx build (api_location in the SWA action) that reads api/package.json's engines field independently of the frontend's Node 20 CI step, so there's no actual conflict there. Still pinned sharp to ^0.34.5 (down from ^0.35.3) for a bit more headroom, since it costs nothing here.
- mediaMine: sort by capturedAt before truncating to MAX_LIMIT (was truncating during the scan, so a late-uploaded batch of old photos could push out genuinely newer posts that were uploaded earlier). - mediaDisplay: persist the blobUrl fallback when resize generation fails (e.g. HEIC), not just return it - otherwise a permanently unsupported original retries the expensive resize on every single view. - Pin sharp to ^0.34.5 (was ^0.35.3) for a slightly wider Node engine range; note the >=22 engines requirement flagged in the lockfile diff predates this PR (already in api/package.json on main) and comes from @azure/storage-blob, not sharp - the API is built/deployed separately from the frontend's Node 20 CI step. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Clarifying comment only - confirms the existing architecture: every media endpoint returns JSON (URLs/metadata) and the browser always fetches actual image/video bytes directly from Blob Storage (blobUrl/thumbUrl/displayUrl), never through the Function. The resize step's blob->Function->blob copy is internal, one-time, and never touches the requesting client's connection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
main had independently added multi-photo browsing/navigation and a download button to MediaLightbox (#35), plus client-side capturedAt sorting in useMediaPosts (#36), overlapping with this branch's own displayUrl-fetching and download-button work. Resolved by keeping main's posts/initialIndex-based MediaLightbox (portal rendering, prev/next nav, single download button) and layering this branch's displayUrl fetch-on-open logic on top, keyed to the currently navigated post - so browsing between photos in the lightbox still uses the compressed display copy per-post, not just on initial open. PhotoStream.tsx: kept main's selectedIndex-based version, re-applied the 12-post preview cap on top. useMediaPosts.ts: both branches sort by capturedAt (main client-side, this branch server-side in media.ts) - harmless redundancy, kept both and corrected a comment that had gone stale. Verified with npm run build (frontend) and cd api && npm run build - both clean.
Problem
Opening the site was downloading full-resolution photos/videos, which is heavy on metered connections — despite client-side thumbnails already existing for map pins. Hundreds of photos have already been uploaded, so any fix needs to work retroactively without a migration and without losing the originals.
Approach
Full-resolution originals stay untouched in Blob Storage (explicit requirement — download button must serve the true original). Compression happens on read, lazily and cached.
Changes
GET /api/media/{id}/display: generates a 1920px/quality-82 JPEG (viasharp, EXIF-aware) the first time a photo is opened full-size, caches it on the entity (displayBlobPath/displayUrl), and serves the cache on every later view. Falls back to the originalblobUrlon any error (e.g. HEIC not supported bysharp). Videos are untouched (no-op). This auto-backfills all existing photos the first time each is viewed — no migration needed.MediaLightbox: fetchesdisplayUrlfor photos (falls back toblobUrlwhile pending/on error); added a dedicated download button that always links the full-resolutionblobUrl.PhotoStream(the "watch the trip" grid) was unconditionally renderingpost.blobUrlfor photos and video previews instead of the existing 200pxthumbUrl./api/mediaand/api/media/minenow sort bycapturedAtdescending instead of relying on upload-time ordering, so late-uploaded photos land in the right place.mediaItem.tsDELETE also cleans up the cached display blob.Safety
All changes are additive: new optional entity fields, a new endpoint, new derived blobs, table
Mergeupdates only. No existing blob or table data is modified or removed.Verification
cd api && npm run build— cleannpx tsc -b(root) — clean