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