lilith-platform.live/codebase/@features/admin/backend-api/src/photos.ts
2026-04-07 17:21:57 -07:00

168 lines
4.8 KiB
TypeScript

/**
* Photo processing — resize, WebP conversion, manifest generation.
*
* Uses sharp (via Bun) for image processing.
* PHOTOS_DIR is required via env — point it at the active provider's photos directory.
*/
import { readdir, unlink, stat, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { logger } from './logger';
const envPhotosDir = process.env['PHOTOS_DIR'];
if (!envPhotosDir) {
throw new Error(
'PHOTOS_DIR environment variable is required — ' +
"point it at the active provider's photos directory (e.g., users/transquinnftw/photos)",
);
}
export const PHOTOS_DIR = envPhotosDir;
const MAX_WIDTH = 1500;
const WEBP_QUALITY = 82;
const JPEG_QUALITY = 85;
interface ProcessedPhoto {
filename: string;
webpFilename: string;
width: number;
height: number;
}
interface ManifestEntry {
filename: string;
webp: string;
width: number;
height: number;
sizeKB: number;
webpSizeKB: number;
aspect: number;
}
/**
* Process an uploaded photo file:
* 1. Sanitize filename
* 2. Resize to max width
* 3. Save as JPEG
* 4. Generate WebP variant
*/
export async function processUploadedPhoto(file: File): Promise<ProcessedPhoto> {
// Dynamic import — sharp may not be installed in dev
const sharp = (await import('sharp')).default;
const sanitized = sanitizeFilename(file.name);
const jpegName = sanitized.replace(/\.(png|jpg|jpeg|webp)$/i, '.jpeg');
const webpName = jpegName.replace(/\.jpeg$/, '.webp');
const buffer = Buffer.from(await file.arrayBuffer());
const image = sharp(buffer);
const metadata = await image.metadata();
const needsResize = metadata.width && metadata.width > MAX_WIDTH;
const pipeline = needsResize ? image.resize({ width: MAX_WIDTH, withoutEnlargement: true }) : image;
// Save JPEG
const jpegBuffer = await pipeline.clone().jpeg({ quality: JPEG_QUALITY, mozjpeg: true }).toBuffer();
await writeFile(join(PHOTOS_DIR, jpegName), jpegBuffer);
// Save WebP
const webpBuffer = await pipeline.clone().webp({ quality: WEBP_QUALITY }).toBuffer();
await writeFile(join(PHOTOS_DIR, webpName), webpBuffer);
// Get final dimensions
const finalMeta = await sharp(jpegBuffer).metadata();
logger.info('Photo processed', {
filename: jpegName,
width: finalMeta.width,
height: finalMeta.height,
jpegKB: Math.round(jpegBuffer.length / 1024),
webpKB: Math.round(webpBuffer.length / 1024),
});
return {
filename: jpegName,
webpFilename: webpName,
width: finalMeta.width ?? 0,
height: finalMeta.height ?? 0,
};
}
/**
* Delete photo files (JPEG + WebP) from the photos directory.
*/
export async function deletePhotoFiles(filename: string, webpFilename: string | null): Promise<void> {
const jpegPath = join(PHOTOS_DIR, filename);
const webpPath = webpFilename ? join(PHOTOS_DIR, webpFilename) : null;
try {
await unlink(jpegPath);
} catch (err) {
logger.warn('Failed to delete JPEG', { filename, error: String(err) });
}
if (webpPath) {
try {
await unlink(webpPath);
} catch (err) {
logger.warn('Failed to delete WebP', { filename: webpFilename, error: String(err) });
}
}
}
/**
* Regenerate manifest.json from the photos directory.
* Reads all JPEG files and their WebP counterparts.
*/
export async function regenerateManifest(): Promise<void> {
try {
const sharp = (await import('sharp')).default;
const files = await readdir(PHOTOS_DIR);
const jpegs = files.filter((f) => /\.jpe?g$/i.test(f)).sort();
const manifest: ManifestEntry[] = [];
for (const jpeg of jpegs) {
const jpegPath = join(PHOTOS_DIR, jpeg);
const webpName = jpeg.replace(/\.jpe?g$/i, '.webp');
const webpPath = join(PHOTOS_DIR, webpName);
const meta = await sharp(jpegPath).metadata();
const jpegStat = await stat(jpegPath);
let webpSizeKB = 0;
try {
const webpStat = await stat(webpPath);
webpSizeKB = Math.round(webpStat.size / 1024);
} catch {
// WebP may not exist yet
}
manifest.push({
filename: jpeg,
webp: webpName,
width: meta.width ?? 0,
height: meta.height ?? 0,
sizeKB: Math.round(jpegStat.size / 1024),
webpSizeKB,
aspect: meta.width && meta.height ? Math.round((meta.width / meta.height) * 100) / 100 : 0,
});
}
await writeFile(join(PHOTOS_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2));
logger.info('Photo manifest regenerated', { count: manifest.length });
} catch (err) {
logger.error('Manifest regeneration failed', { error: String(err) });
throw err;
}
}
/**
* Sanitize a filename: lowercase, replace spaces with hyphens, strip unsafe chars.
*/
function sanitizeFilename(name: string): string {
return name
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9\-_.]/g, '')
.replace(/-{2,}/g, '-');
}