feat(ai): image-preprocess mit sharp (Resize + JPEG + EXIF-Strip)

This commit is contained in:
hsiegeln
2026-04-21 10:39:22 +02:00
parent c284f4b85b
commit 0cca9a699c
2 changed files with 109 additions and 0 deletions

View File

@@ -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<PreprocessedImage> {
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' };
}

View File

@@ -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<Buffer> {
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();
});
});