perf: disk-cache grid thumbnails and document 2026-07-09 fixes
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user