168 lines
4.8 KiB
TypeScript
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, '-');
|
|
}
|