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:
61
src/lib/server/images/storage.ts
Normal file
61
src/lib/server/images/storage.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/lib/server/images/thumbnails.ts
Normal file
23
src/lib/server/images/thumbnails.ts
Normal 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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user