feat(ai): image-preprocess mit sharp (Resize + JPEG + EXIF-Strip)
This commit is contained in:
38
src/lib/server/ai/image-preprocess.ts
Normal file
38
src/lib/server/ai/image-preprocess.ts
Normal 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' };
|
||||||
|
}
|
||||||
71
tests/unit/image-preprocess.test.ts
Normal file
71
tests/unit/image-preprocess.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user