perf: disk-cache grid thumbnails and document 2026-07-09 fixes
CI / skip-ci-check (pull_request) Successful in 6s
CI / docker-ci (pull_request) Successful in 7s
CI / secret-scan (pull_request) Successful in 12s

Cache resized thumbs under .cache/photo-thumbnails (360px) so repeat
grid loads skip Sharp. Document ticket/infra fixes; drop unused SVGs.
This commit is contained in:
2026-07-09 14:54:08 -04:00
parent ffddeca268
commit 3769c897b0
10 changed files with 96 additions and 144 deletions
@@ -3,7 +3,7 @@ import { fastApiV1Url } from '@/lib/server/fastapi-backend';
import { prisma } from '@/lib/db';
import { readFile } from 'fs/promises';
import { createReadStream } from 'fs';
import { existsSync, statSync } from 'fs';
import { existsSync, statSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
import path from 'path';
// Conditionally import sharp - handle case where libvips is not installed
@@ -18,6 +18,21 @@ try {
const WATERMARK_BUCKET_SIZE = 200;
const watermarkCache = new Map<string, Buffer>();
const THUMB_CACHE_DIR = path.join(process.cwd(), '.cache', 'photo-thumbnails');
const THUMB_MAX = 360; // grid cells are small; keep bytes down (#26)
function ensureThumbCacheDir() {
if (!existsSync(THUMB_CACHE_DIR)) {
mkdirSync(THUMB_CACHE_DIR, { recursive: true });
}
}
function thumbCachePath(photoId: number, watermark: boolean, mtimeMs: number) {
const tag = watermark ? 'wm' : 'plain';
return path.join(THUMB_CACHE_DIR, `${photoId}-${tag}-${THUMB_MAX}-${mtimeMs}.jpg`);
}
async function getWatermarkOverlay(baseWidth: number, baseHeight: number) {
if (!sharp) {
throw new Error('Sharp library not available');
@@ -260,30 +275,50 @@ export async function GET(
},
});
// Grid thumbnails: resize photos (videos handled above) — #26
// Grid thumbnails: resize + disk cache (videos handled above) — #26
if (thumbnail && sharp) {
try {
ensureThumbCacheDir();
const mtimeMs = statSync(filePath).mtimeMs;
const cacheFile = thumbCachePath(photoId, watermark, mtimeMs);
if (existsSync(cacheFile)) {
const cached = readFileSync(cacheFile);
return new NextResponse(cached, {
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=604800, immutable',
'X-Thumb-Cache': 'HIT',
},
});
}
let pipeline = sharp(imageBuffer).rotate().resize({
width: 480,
height: 480,
width: THUMB_MAX,
height: THUMB_MAX,
fit: 'inside',
withoutEnlargement: true,
});
if (watermark) {
const metadata = await pipeline.metadata();
const baseWidth = metadata.width ?? 480;
const baseHeight = metadata.height ?? 480;
const baseWidth = metadata.width ?? THUMB_MAX;
const baseHeight = metadata.height ?? THUMB_MAX;
const overlayBuffer = await getWatermarkOverlay(baseWidth, baseHeight);
pipeline = sharp(imageBuffer)
.rotate()
.resize({ width: 480, height: 480, fit: 'inside', withoutEnlargement: true })
.resize({ width: THUMB_MAX, height: THUMB_MAX, fit: 'inside', withoutEnlargement: true })
.composite([{ input: overlayBuffer, gravity: 'center', blend: 'hard-light' }]);
}
const result = await pipeline.jpeg({ quality: 75 }).toBuffer();
const result = await pipeline.jpeg({ quality: 68, mozjpeg: true }).toBuffer();
try {
writeFileSync(cacheFile, result);
} catch (e) {
console.warn('thumb cache write failed', e);
}
return new NextResponse(result as unknown as BodyInit, {
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=86400',
'Cache-Control': 'public, max-age=604800, immutable',
'X-Thumb-Cache': 'MISS',
},
});
} catch (error) {