feat(03-01): add storage and thumbnail utilities

- Add storage.ts with directory management and file I/O
- Add thumbnails.ts with EXIF-aware thumbnail generation
- Thumbnails always output as JPEG with 80% quality
This commit is contained in:
Thomas Richter
2026-01-29 15:23:08 +01:00
parent f5b5034f07
commit 0987d16dc0
2 changed files with 84 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
import { mkdir, writeFile, unlink } from 'node:fs/promises';
import { join } from 'node:path';
export const UPLOAD_DIR = 'data/uploads';
export const ORIGINALS_DIR = 'data/uploads/originals';
export const THUMBNAILS_DIR = 'data/uploads/thumbnails';
/**
* Ensure upload directories exist
*/
export async function ensureDirectories(): Promise<void> {
await mkdir(ORIGINALS_DIR, { recursive: true });
await mkdir(THUMBNAILS_DIR, { recursive: true });
}
/**
* Save original image to filesystem
*/
export async function saveOriginal(id: string, ext: string, buffer: Buffer): Promise<void> {
const path = getOriginalPath(id, ext);
await writeFile(path, buffer);
}
/**
* Save thumbnail to filesystem (always jpg)
*/
export async function saveThumbnail(id: string, buffer: Buffer): Promise<void> {
const path = getThumbnailPath(id);
await writeFile(path, buffer);
}
/**
* Get path to original image
*/
export function getOriginalPath(id: string, ext: string): string {
return join(ORIGINALS_DIR, `${id}.${ext}`);
}
/**
* Get path to thumbnail (always jpg)
*/
export function getThumbnailPath(id: string): string {
return join(THUMBNAILS_DIR, `${id}.jpg`);
}
/**
* Delete both original and thumbnail files
* Does not throw if files don't exist
*/
export async function deleteImage(id: string, ext: string): Promise<void> {
try {
await unlink(getOriginalPath(id, ext));
} catch {
// File may not exist, ignore
}
try {
await unlink(getThumbnailPath(id));
} catch {
// File may not exist, ignore
}
}

View File

@@ -0,0 +1,23 @@
import sharp from 'sharp';
/**
* Generate a thumbnail from an image buffer
*
* CRITICAL: Uses sharp.rotate() first to handle EXIF orientation from mobile photos.
* Without this, photos taken in portrait mode may appear rotated.
*
* @param buffer - Original image buffer
* @param size - Thumbnail size (default 150px)
* @returns Thumbnail buffer as JPEG
*/
export async function generateThumbnail(buffer: Buffer, size: number = 150): Promise<Buffer> {
return sharp(buffer)
.rotate() // Auto-rotate based on EXIF orientation
.resize(size, size, {
fit: 'cover',
position: 'center',
withoutEnlargement: true
})
.jpeg({ quality: 80 })
.toBuffer();
}