Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
594 changes: 593 additions & 1 deletion api/package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"dependencies": {
"@azure/data-tables": "^13.3.2",
"@azure/functions": "^4.16.2",
"@azure/storage-blob": "^12.33.0"
"@azure/storage-blob": "^12.33.0",
"sharp": "^0.34.5"
},
"devDependencies": {
"azure-functions-core-tools": "^4",
Expand Down
28 changes: 22 additions & 6 deletions api/src/functions/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@ import { getMediaTable, mediaPartitionKey, type MediaEntity } from '../mediaTabl
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 500;

// Upper bound on how many rows we'll ever pull from the partition to sort
// in memory (see below) - just a sanity ceiling so a single trip's archive
// growing far beyond "hundreds" of posts can't make this scan unbounded.
const SCAN_CAP = 5000;

export interface MediaPost {
id: string;
mediaType: MediaEntity['mediaType'];
blobUrl: string;
thumbUrl?: string;
displayUrl?: string;
lat?: number;
lon?: number;
capturedAt: string;
Expand All @@ -29,10 +35,14 @@ export async function media(request: HttpRequest, context: InvocationContext): P
try {
const table = await getMediaTable();
// RowKeys are generated with an inverted-timestamp prefix (see
// generateMediaRowKey in mediaTable.ts), so Table Storage already
// returns them newest-first within the partition. Stop as soon as we
// have `limit` posts instead of listing the whole partition and
// sorting in memory - keeps this cheap as uploads pile up.
// generateMediaRowKey in mediaTable.ts), so Table Storage returns them
// *upload*-time-newest-first within the partition - but that's not
// necessarily the same order as `capturedAt` (e.g. someone uploads an
// older photo later), and posts should read back in the order they
// were taken, not the order they were uploaded. So we list the whole
// partition (still cheap - a single trip's partition, bounded by
// SCAN_CAP) and sort by capturedAt ourselves instead of relying on
// RowKey order + stopping early.
const entities = table.listEntities<MediaEntity>({
queryOptions: { filter: `PartitionKey eq '${mediaPartitionKey()}'` },
});
Expand All @@ -44,17 +54,23 @@ export async function media(request: HttpRequest, context: InvocationContext): P
mediaType: entity.mediaType,
blobUrl: entity.blobUrl,
thumbUrl: entity.thumbUrl,
displayUrl: entity.displayUrl,
lat: entity.lat,
lon: entity.lon,
capturedAt: entity.capturedAt,
uploadedAt: entity.uploadedAt,
});
if (posts.length >= limit) break;
if (posts.length >= SCAN_CAP) break;
}

// capturedAt is self-reported/client-clock (see mediaComplete.ts) but
// always falls back to a valid uploadedAt server timestamp when
// missing/unparseable, so this sort is always well-defined.
posts.sort((a, b) => new Date(b.capturedAt).getTime() - new Date(a.capturedAt).getTime());

return {
status: 200,
jsonBody: posts,
jsonBody: posts.slice(0, limit),
headers: { 'Cache-Control': 'no-store' },
};
} catch (error) {
Expand Down
131 changes: 131 additions & 0 deletions api/src/functions/mediaDisplay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
import sharp from 'sharp';
import { getMediaTable, mediaPartitionKey, type MediaEntity } from '../mediaTable';
import { getMediaContainer, generateDisplayBlobPath } from '../mediaBlob';

// Originals already at or below this size aren't worth recompressing -
// re-encoding a small JPEG a second time only costs quality for little to
// no byte savings, so we just point `displayUrl` at the original instead.
const SKIP_RECOMPRESSION_BELOW_BYTES = 600 * 1024;

// Long-edge cap and JPEG quality for the generated "display" copy. Chosen
// to look essentially unchanged on a phone/laptop screen while cutting a
// typical 12MP+ camera photo down to a few hundred KB.
const MAX_EDGE = 1920;
const JPEG_QUALITY = 82;

/**
* GET /api/media/{id}/display
*
* Lazily generates (once) and thereafter serves a cached, mid-size JPEG
* copy of a photo post, so opening it in the viewer doesn't require
* downloading the full-resolution original. The original blob is never
* modified - this only ever adds a second, smaller blob alongside it.
*
* First caller for a given post pays the one-time cost of a resize; every
* caller after that (including this same post being returned by /api/media
* once displayUrl is persisted) is a plain, free blob read. This also
* means posts uploaded *before* this endpoint existed get a display copy
* automatically the first time anyone opens them - no backfill needed.
*
* Public/anonymous, same exposure level as GET /api/media - this only
* reads/derives from data that's already publicly viewable via blobUrl.
*
* Note on data flow: this endpoint's HTTP response is always just a small
* JSON `{ displayUrl }` payload, never image bytes - the browser's <img>
* tag then fetches those bytes directly from Blob Storage, same as
* blobUrl/thumbUrl. Only the one-time resize (below) reads the original
* and writes the resized copy through this Function's own memory - that's
* unavoidable since `sharp` needs the pixels to resize them - but it never
* touches the requesting client's connection either way.
*/
export async function mediaDisplay(request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> {
const id = request.params.id;
if (!id) return { status: 400, body: 'Missing id' };

const table = await getMediaTable();
let entity: MediaEntity;
try {
entity = await table.getEntity<MediaEntity>(mediaPartitionKey(), id);
} catch {
return { status: 404, body: 'Post not found' };
}

// Already generated (or already aliased to the original) - fast path,
// no blob reads, no image processing.
if (entity.displayUrl) {
return { status: 200, jsonBody: { displayUrl: entity.displayUrl }, headers: { 'Cache-Control': 'no-store' } };
}

// Videos aren't resized here (see plan notes - re-encoding video is out
// of scope for now) - the frontend should just keep using blobUrl.
if (entity.mediaType !== 'photo') {
return { status: 200, jsonBody: { displayUrl: entity.blobUrl }, headers: { 'Cache-Control': 'no-store' } };
}

try {
const container = await getMediaContainer();
const originalBlob = container.getBlockBlobClient(entity.blobPath);

const properties = await originalBlob.getProperties();
if ((properties.contentLength ?? Infinity) <= SKIP_RECOMPRESSION_BELOW_BYTES) {
await table.updateEntity<Partial<MediaEntity>>(
{ partitionKey: entity.partitionKey, rowKey: entity.rowKey, displayUrl: entity.blobUrl },
'Merge',
);
return { status: 200, jsonBody: { displayUrl: entity.blobUrl }, headers: { 'Cache-Control': 'no-store' } };
}

const original = await originalBlob.downloadToBuffer();
// rotate() with no args applies the EXIF orientation tag (if any) then
// strips it, so the resized copy displays right-side-up everywhere,
// including viewers that ignore EXIF orientation. withoutEnlargement
// guards against upscaling smaller-than-MAX_EDGE originals.
const resized = await sharp(original)
.rotate()
.resize({ width: MAX_EDGE, height: MAX_EDGE, fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: JPEG_QUALITY })
.toBuffer();

const displayBlobPath = generateDisplayBlobPath();
const displayBlob = container.getBlockBlobClient(displayBlobPath);
await displayBlob.upload(resized, resized.length, {
blobHTTPHeaders: { blobContentType: 'image/jpeg' },
});

const displayUrl = displayBlob.url;
await table.updateEntity<Partial<MediaEntity>>(
{ partitionKey: entity.partitionKey, rowKey: entity.rowKey, displayBlobPath, displayUrl },
'Merge',
);

return { status: 200, jsonBody: { displayUrl }, headers: { 'Cache-Control': 'no-store' } };
} 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);

// Persist the fallback too (aliasing displayUrl to blobUrl), same as
// the "already small enough" path above. Without this, a permanently
// unsupported original (e.g. HEIC) would retry the expensive resize -
// and lose to a full sharp() decode attempt - on every single view.
try {
await table.updateEntity<Partial<MediaEntity>>(
{ partitionKey: entity.partitionKey, rowKey: entity.rowKey, displayUrl: entity.blobUrl },
'Merge',
);
} catch (persistError) {
context.warn('Failed to persist blobUrl fallback for ' + id, persistError);
}

return { status: 200, jsonBody: { displayUrl: entity.blobUrl }, headers: { 'Cache-Control': 'no-store' } };
}
}

app.http('mediaDisplay', {
methods: ['GET'],
authLevel: 'anonymous',
route: 'media/{id}/display',
handler: mediaDisplay,
});
3 changes: 3 additions & 0 deletions api/src/functions/mediaItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ export async function mediaItemDelete(request: HttpRequest, context: InvocationC
if (entity.thumbBlobPath) {
await container.getBlockBlobClient(entity.thumbBlobPath).deleteIfExists();
}
if (entity.displayBlobPath) {
await container.getBlockBlobClient(entity.displayBlobPath).deleteIfExists();
}
await table.deleteEntity(mediaPartitionKey(), id);
return { status: 204 };
} catch (error) {
Expand Down
16 changes: 14 additions & 2 deletions api/src/functions/mediaMine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import type { MediaPost } from './media';

const MAX_LIMIT = 500;

// Upper bound on how many rows we'll ever pull for a single uploader to
// sort in memory - see media.ts for the same reasoning. Just a sanity
// ceiling, well above what any real uploader is expected to hit.
const SCAN_CAP = 5000;

/**
* Lists a single uploader's own posts, keyed by their self-reported
* email. Anonymous/public read, same exposure level as `/api/media` -
Expand Down Expand Up @@ -33,17 +38,24 @@ export async function mediaMine(request: HttpRequest, context: InvocationContext
mediaType: entity.mediaType,
blobUrl: entity.blobUrl,
thumbUrl: entity.thumbUrl,
displayUrl: entity.displayUrl,
lat: entity.lat,
lon: entity.lon,
capturedAt: entity.capturedAt,
uploadedAt: entity.uploadedAt,
});
if (posts.length >= MAX_LIMIT) break;
if (posts.length >= SCAN_CAP) break;
}

// Sort by capturedAt (not RowKey/upload order - see media.ts) *before*
// truncating to MAX_LIMIT, so an uploader with more posts than
// MAX_LIMIT still gets their genuinely most-recently-captured posts,
// not whichever happened to be scanned first.
posts.sort((a, b) => new Date(b.capturedAt).getTime() - new Date(a.capturedAt).getTime());

return {
status: 200,
jsonBody: posts,
jsonBody: posts.slice(0, MAX_LIMIT),
headers: { 'Cache-Control': 'no-store' },
};
} catch (error) {
Expand Down
13 changes: 13 additions & 0 deletions api/src/mediaBlob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,19 @@ export function generateThumbBlobPath(): string {
return `${getCurrentTripId()}/${day}/thumb-${id}.jpg`;
}

/**
* "Display" blobs are the mid-size viewer copy generated lazily,
* server-side, on first view (see mediaDisplay.ts) - unlike the thumbnail
* they're not created up-front at upload time, but the path shape
* (always a JPEG, same day-bucketing) mirrors generateThumbBlobPath() for
* consistency.
*/
export function generateDisplayBlobPath(): string {
const day = new Date().toISOString().slice(0, 10);
const id = crypto.randomUUID();
return `${getCurrentTripId()}/${day}/display-${id}.jpg`;
}

/**
* Issues a short-lived, single-blob, write-only SAS URL. The caller (a
* phone in the field) PUTs the file bytes directly to Blob Storage using
Expand Down
8 changes: 8 additions & 0 deletions api/src/mediaTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ export interface MediaEntity {
// in some browsers) - falls back to blobUrl on the frontend when absent.
thumbBlobPath?: string;
thumbUrl?: string;
// Mid-size JPEG generated lazily, server-side, the first time anyone
// opens the post in the full-size viewer (see mediaDisplay.ts). Keeps
// the original full-resolution upload untouched in storage while
// avoiding a multi-MB download every time someone just wants to *look*
// at the photo. Optional/lazy for the same reasons as thumbBlobPath -
// frontend falls back to blobUrl until/unless this exists.
displayBlobPath?: string;
displayUrl?: string;
}

export function mediaPartitionKey(): string {
Expand Down
57 changes: 46 additions & 11 deletions src/components/MediaLightbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,36 @@ export default function MediaLightbox({
const post = posts[safeIndex];
const canNavigate = posts.length > 1;

// The full-resolution original (post.blobUrl) can be several MB - bad on
// a metered connection just to *look* at a photo. If the post doesn't
// already carry a generated display-size copy (see mediaDisplay.ts),
// request one on open (and again on every navigation, per-post); the
// server generates (and caches) it lazily on first request, so this is a
// one-time cost per post, not per view. Shown immediately with the
// full-size blobUrl as a fallback while that request is in flight (or if
// it fails) so opening/navigating never stalls.
const [displayUrl, setDisplayUrl] = useState(post?.displayUrl);

useEffect(() => {
setDisplayUrl(post?.displayUrl);
if (!post || post.mediaType !== 'photo' || post.displayUrl) return;

let cancelled = false;
fetch(`/api/media/${post.id}/display`)
.then((res) => (res.ok ? res.json() : null))
.then((data: { displayUrl?: string } | null) => {
if (!cancelled && data?.displayUrl) setDisplayUrl(data.displayUrl);
})
.catch(() => {
// Network hiccup or the endpoint failed - blobUrl fallback below
// already covers this, nothing else to do.
});

return () => {
cancelled = true;
};
}, [post?.id, post?.mediaType, post?.displayUrl]);

function goPrev() {
setIndex((i) => (Math.min(i, posts.length - 1) - 1 + posts.length) % posts.length);
}
Expand Down Expand Up @@ -66,6 +96,21 @@ export default function MediaLightbox({
aria-modal="true"
aria-label={post.mediaType === 'video' ? 'Trip video viewer' : 'Trip photo viewer'}
>
{/* Always the full-resolution original (post.blobUrl), never
displayUrl/thumbUrl - the viewer's compressed copy is meant to
save bandwidth for casual viewing, not to be what someone
actually keeps. */}
<a
href={post.blobUrl}
download
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
aria-label={post.mediaType === 'video' ? 'Download full-size video' : 'Download full-size photo'}
className="absolute top-4 right-[68px] z-10 w-11 h-11 flex items-center justify-center rounded-full bg-black/70 text-white ring-1 ring-white/30 hover:bg-black/90 transition-colors"
>
<Download size={20} strokeWidth={1.5} />
</a>
<button
onClick={(e) => {
e.stopPropagation();
Expand All @@ -77,16 +122,6 @@ export default function MediaLightbox({
<X size={22} strokeWidth={1.5} />
</button>

<a
href={post.blobUrl}
download
onClick={(e) => e.stopPropagation()}
aria-label="Download original"
className="absolute top-4 right-[68px] z-10 w-11 h-11 flex items-center justify-center rounded-full bg-black/70 text-white ring-1 ring-white/30 hover:bg-black/90 transition-colors"
>
<Download size={20} strokeWidth={1.5} />
</a>

{canNavigate && (
<button
onClick={(e) => {
Expand All @@ -113,7 +148,7 @@ export default function MediaLightbox({
) : (
<img
key={post.id}
src={post.blobUrl}
src={displayUrl ?? post.blobUrl}
alt="Trip photo"
className="max-w-full max-h-full rounded-lg object-contain"
onClick={(e) => e.stopPropagation()}
Expand Down
Loading