Skip to content

Compress media views without touching stored originals - #37

Merged
TomProkop merged 4 commits into
mainfrom
users/tomas.prokop/compress-uploaded-media
Jul 27, 2026
Merged

Compress media views without touching stored originals#37
TomProkop merged 4 commits into
mainfrom
users/tomas.prokop/compress-uploaded-media

Conversation

@TomProkop

Copy link
Copy Markdown
Member

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

  • New GET /api/media/{id}/display: generates a 1920px/quality-82 JPEG (via sharp, 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 original blobUrl on any error (e.g. HEIC not supported by sharp). Videos are untouched (no-op). This auto-backfills all existing photos the first time each is viewed — no migration needed.
  • MediaLightbox: fetches displayUrl for photos (falls back to blobUrl while pending/on error); added a dedicated download button that always links the full-resolution blobUrl.
  • Fixed the real cause of ~500MB page loads: PhotoStream (the "watch the trip" grid) was unconditionally rendering post.blobUrl for photos and video previews instead of the existing 200px thumbUrl.
  • Capped the live grid to 12 most recent posts so it stays a quick teaser instead of growing page-length as more photos come in; map pins are unaffected (they still use the full result set).
  • Sorting fix: /api/media and /api/media/mine now sort by capturedAt descending instead of relying on upload-time ordering, so late-uploaded photos land in the right place.
  • mediaItem.ts DELETE also cleans up the cached display blob.

Safety

All changes are additive: new optional entity fields, a new endpoint, new derived blobs, table Merge updates only. No existing blob or table data is modified or removed.

Verification

  • cd api && npm run build — clean
  • npx tsc -b (root) — clean
  • No deployed environment available for live testing; verified via local build/typecheck only.

- 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}/display to generate/cache a 1920px JPEG “display” copy for photos (using sharp) and return its URL.
  • Updated the frontend lightbox to prefer displayUrl for photo viewing while providing an explicit download link that always uses the original blobUrl.
  • Fixed PhotoStream to use thumbnails for photo/video previews, capped the preview grid to 12 items, and updated API listing to sort by capturedAt desc.

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.

Comment thread api/src/functions/mediaMine.ts Outdated
Comment on lines 39 to 42
@@ -41,6 +42,10 @@ export async function mediaMine(request: HttpRequest, context: InvocationContext
if (posts.length >= MAX_LIMIT) break;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +95 to +101
} 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' } };
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread api/package-lock.json
Comment on lines +20 to 22
"engines": {
"node": ">=22"
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

TomProkop and others added 3 commits July 27, 2026 22:50
- 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants