diff --git a/src/lib/server/ai/image-preprocess.ts b/src/lib/server/ai/image-preprocess.ts new file mode 100644 index 0000000..3b51d0f --- /dev/null +++ b/src/lib/server/ai/image-preprocess.ts @@ -0,0 +1,38 @@ +import sharp from 'sharp'; + +const MAX_EDGE = 1600; +const JPEG_QUALITY = 85; + +export type PreprocessedImage = { + buffer: Buffer; + mimeType: 'image/jpeg'; +}; + +// Resize auf max 1600px lange Kante, JPEG re-encode, Metadata strippen. +// sharp liest HEIC/HEIF transparent, wenn libheif im libvips-Build enthalten ist +// (in Alpine's vips-dev + in den offiziellen sharp-Prebuilds). +export async function preprocessImage(input: Buffer): Promise { + const pipeline = sharp(input, { failOn: 'error' }).rotate(); // respect EXIF orientation + const meta = await pipeline.metadata(); + if (!meta.width || !meta.height) { + throw new Error('Unable to read image dimensions'); + } + + const longEdge = Math.max(meta.width, meta.height); + const resized = + longEdge > MAX_EDGE + ? pipeline.resize({ + width: meta.width >= meta.height ? MAX_EDGE : undefined, + height: meta.height > meta.width ? MAX_EDGE : undefined, + withoutEnlargement: true + }) + : pipeline; + + // Default-Verhalten seit sharp 0.33: alle Metadata (EXIF/IPTC/XMP) werden + // gestripped. Nur `.keepMetadata()`/`.keepExif()` würde sie erhalten. + const buffer = await resized + .jpeg({ quality: JPEG_QUALITY, mozjpeg: true }) + .toBuffer(); + + return { buffer, mimeType: 'image/jpeg' }; +} diff --git a/tests/unit/image-preprocess.test.ts b/tests/unit/image-preprocess.test.ts new file mode 100644 index 0000000..9ce14c0 --- /dev/null +++ b/tests/unit/image-preprocess.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import sharp from 'sharp'; +import { preprocessImage } from '../../src/lib/server/ai/image-preprocess'; + +async function makeTestImage( + width: number, + height: number, + format: 'jpeg' | 'png' | 'webp' = 'jpeg' +): Promise { + return sharp({ + create: { + width, + height, + channels: 3, + background: { r: 128, g: 128, b: 128 } + } + }) + .toFormat(format) + .toBuffer(); +} + +describe('preprocessImage', () => { + it('resizes a landscape image so long edge <= 1600px', async () => { + const input = await makeTestImage(4000, 2000); + const { buffer, mimeType } = await preprocessImage(input); + const meta = await sharp(buffer).metadata(); + expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(1600); + expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeGreaterThan(1000); + expect(mimeType).toBe('image/jpeg'); + }); + + it('resizes a portrait image so long edge <= 1600px', async () => { + const input = await makeTestImage(2000, 4000); + const { buffer } = await preprocessImage(input); + const meta = await sharp(buffer).metadata(); + expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(1600); + }); + + it('does not upscale smaller images', async () => { + const input = await makeTestImage(800, 600); + const { buffer } = await preprocessImage(input); + const meta = await sharp(buffer).metadata(); + expect(meta.width).toBe(800); + expect(meta.height).toBe(600); + }); + + it('converts PNG input to JPEG output', async () => { + const input = await makeTestImage(1000, 1000, 'png'); + const { buffer, mimeType } = await preprocessImage(input); + const meta = await sharp(buffer).metadata(); + expect(meta.format).toBe('jpeg'); + expect(mimeType).toBe('image/jpeg'); + }); + + it('strips EXIF metadata', async () => { + const input = await sharp({ + create: { width: 100, height: 100, channels: 3, background: '#888' } + }) + .withMetadata({ exif: { IFD0: { Copyright: 'test' } } }) + .jpeg() + .toBuffer(); + const { buffer } = await preprocessImage(input); + const meta = await sharp(buffer).metadata(); + expect(meta.exif).toBeUndefined(); + }); + + it('rejects non-image buffers', async () => { + const notAnImage = Buffer.from('hello world'); + await expect(preprocessImage(notAnImage)).rejects.toThrow(); + }); +});