mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Centralizes the sharp format key type into a single `SharpFormat` alias derived from `toFormat()`'s signature (exported from @snapotter/image-engine, imported by the API consumers), replacing the duplicated `keyof FormatEnum` definitions. Adds convert/compress format round-trip tests covering webp/avif/png/jpeg. Part of #325.
430 lines
13 KiB
TypeScript
430 lines
13 KiB
TypeScript
import sharp from "sharp";
|
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
import { processImage } from "../src/engine.js";
|
|
import { detectFormat } from "../src/formats/detect.js";
|
|
import { brightness } from "../src/operations/brightness.js";
|
|
import { colorChannels } from "../src/operations/color-channels.js";
|
|
import { compress } from "../src/operations/compress.js";
|
|
import { contrast } from "../src/operations/contrast.js";
|
|
import { convert } from "../src/operations/convert.js";
|
|
import { crop } from "../src/operations/crop.js";
|
|
import { flip } from "../src/operations/flip.js";
|
|
import { grayscale } from "../src/operations/grayscale.js";
|
|
import { invert } from "../src/operations/invert.js";
|
|
import { resize } from "../src/operations/resize.js";
|
|
import { rotate } from "../src/operations/rotate.js";
|
|
import { saturation } from "../src/operations/saturation.js";
|
|
import { sepia } from "../src/operations/sepia.js";
|
|
import { stripMetadata } from "../src/operations/strip-metadata.js";
|
|
import type { OutputFormat, Sharp, SharpFormat } from "../src/types.js";
|
|
import { getImageInfo } from "../src/utils/metadata.js";
|
|
import { extToMime, formatToExt, formatToMime, mimeToExt } from "../src/utils/mime.js";
|
|
|
|
// Generate a 100x100 red PNG buffer for testing
|
|
let testBuffer: Buffer;
|
|
let testImage: () => Sharp;
|
|
|
|
const SHARP_FORMAT_CASES = [
|
|
{ sharpFormat: "webp", operationFormat: "webp", expectedFormat: "webp" },
|
|
{ sharpFormat: "avif", operationFormat: "avif", expectedFormat: "avif" },
|
|
{ sharpFormat: "png", operationFormat: "png", expectedFormat: "png" },
|
|
{ sharpFormat: "jpeg", operationFormat: "jpg", expectedFormat: "jpeg" },
|
|
] as const satisfies ReadonlyArray<{
|
|
sharpFormat: SharpFormat;
|
|
operationFormat: OutputFormat;
|
|
expectedFormat: string;
|
|
}>;
|
|
|
|
function tinyTestImage(): Sharp {
|
|
return sharp({
|
|
create: {
|
|
width: 4,
|
|
height: 4,
|
|
channels: 3,
|
|
background: { r: 255, g: 0, b: 0 },
|
|
},
|
|
});
|
|
}
|
|
|
|
async function expectOutputFormat(buffer: Buffer, expectedFormat: string): Promise<void> {
|
|
const meta = await sharp(buffer).metadata();
|
|
const format = meta.format === "heif" ? "avif" : meta.format;
|
|
expect(format).toBe(expectedFormat);
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
testBuffer = await sharp({
|
|
create: {
|
|
width: 100,
|
|
height: 100,
|
|
channels: 3,
|
|
background: { r: 255, g: 0, b: 0 },
|
|
},
|
|
})
|
|
.png()
|
|
.toBuffer();
|
|
|
|
testImage = () => sharp(testBuffer);
|
|
});
|
|
|
|
describe("resize", () => {
|
|
it("should resize to 50x50", async () => {
|
|
const result = await resize(testImage(), { width: 50, height: 50 });
|
|
const buf = await result.toBuffer();
|
|
const meta = await sharp(buf).metadata();
|
|
expect(meta.width).toBe(50);
|
|
expect(meta.height).toBe(50);
|
|
});
|
|
|
|
it("should resize by percentage", async () => {
|
|
const result = await resize(testImage(), { percentage: 50 });
|
|
const buf = await result.toBuffer();
|
|
const meta = await sharp(buf).metadata();
|
|
expect(meta.width).toBe(50);
|
|
expect(meta.height).toBe(50);
|
|
});
|
|
|
|
it("should throw on zero width", async () => {
|
|
await expect(resize(testImage(), { width: 0 })).rejects.toThrow();
|
|
});
|
|
|
|
it("should throw when no dimensions provided", async () => {
|
|
await expect(resize(testImage(), {})).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("crop", () => {
|
|
it("should crop 25x25 at (10,10)", async () => {
|
|
const result = await crop(testImage(), {
|
|
left: 10,
|
|
top: 10,
|
|
width: 25,
|
|
height: 25,
|
|
});
|
|
const buf = await result.toBuffer();
|
|
const meta = await sharp(buf).metadata();
|
|
expect(meta.width).toBe(25);
|
|
expect(meta.height).toBe(25);
|
|
});
|
|
|
|
it("should throw on out-of-bounds crop", async () => {
|
|
await expect(crop(testImage(), { left: 90, top: 90, width: 20, height: 20 })).rejects.toThrow();
|
|
});
|
|
|
|
it("should throw on zero dimensions", async () => {
|
|
await expect(crop(testImage(), { left: 0, top: 0, width: 0, height: 10 })).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("rotate", () => {
|
|
it("should rotate 90 degrees and swap dimensions", async () => {
|
|
// Create a non-square image to verify dimension swap
|
|
const rectBuffer = await sharp({
|
|
create: {
|
|
width: 100,
|
|
height: 50,
|
|
channels: 3,
|
|
background: { r: 255, g: 0, b: 0 },
|
|
},
|
|
})
|
|
.png()
|
|
.toBuffer();
|
|
|
|
const result = await rotate(sharp(rectBuffer), { angle: 90 });
|
|
const buf = await result.toBuffer();
|
|
const meta = await sharp(buf).metadata();
|
|
expect(meta.width).toBe(50);
|
|
expect(meta.height).toBe(100);
|
|
});
|
|
|
|
it("should rotate non-90 angle with background", async () => {
|
|
const result = await rotate(testImage(), {
|
|
angle: 45,
|
|
background: "#FF0000",
|
|
});
|
|
const meta = await result.metadata();
|
|
expect(meta.width).toBeGreaterThan(0);
|
|
expect(meta.height).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe("flip", () => {
|
|
it("should flip horizontally without error", async () => {
|
|
const result = await flip(testImage(), { horizontal: true });
|
|
const buf = await result.toBuffer();
|
|
expect(buf.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("should flip vertically without error", async () => {
|
|
const result = await flip(testImage(), { vertical: true });
|
|
const buf = await result.toBuffer();
|
|
expect(buf.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("should flip both directions", async () => {
|
|
const result = await flip(testImage(), { horizontal: true, vertical: true });
|
|
const buf = await result.toBuffer();
|
|
expect(buf.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("should throw when neither direction specified", async () => {
|
|
await expect(flip(testImage(), {})).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("convert", () => {
|
|
it("should convert to webp", async () => {
|
|
const result = await convert(testImage(), { format: "webp" });
|
|
const buf = await result.toBuffer();
|
|
const meta = await sharp(buf).metadata();
|
|
expect(meta.format).toBe("webp");
|
|
});
|
|
|
|
it("should convert to jpg with quality", async () => {
|
|
const result = await convert(testImage(), { format: "jpg", quality: 80 });
|
|
const buf = await result.toBuffer();
|
|
const meta = await sharp(buf).metadata();
|
|
expect(meta.format).toBe("jpeg");
|
|
});
|
|
|
|
it("should throw on invalid quality", async () => {
|
|
await expect(convert(testImage(), { format: "png", quality: 0 })).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("compress", () => {
|
|
it("should compress at quality 50 and produce smaller output", async () => {
|
|
// Use a larger image for better compression ratio visibility
|
|
const largeBuffer = await sharp({
|
|
create: {
|
|
width: 500,
|
|
height: 500,
|
|
channels: 3,
|
|
background: { r: 255, g: 128, b: 0 },
|
|
},
|
|
})
|
|
.jpeg({ quality: 100 })
|
|
.toBuffer();
|
|
|
|
const result = await compress(sharp(largeBuffer), {
|
|
quality: 50,
|
|
format: "jpg",
|
|
});
|
|
const buf = await result.toBuffer();
|
|
expect(buf.length).toBeLessThan(largeBuffer.length);
|
|
});
|
|
|
|
it("should throw on invalid quality", async () => {
|
|
await expect(compress(testImage(), { quality: 0 })).rejects.toThrow();
|
|
});
|
|
|
|
it("should compress to target size", async () => {
|
|
const largeBuffer = await sharp({
|
|
create: {
|
|
width: 500,
|
|
height: 500,
|
|
channels: 3,
|
|
background: { r: 255, g: 128, b: 0 },
|
|
},
|
|
})
|
|
.jpeg({ quality: 100 })
|
|
.toBuffer();
|
|
|
|
const targetSize = Math.round(largeBuffer.length * 0.5);
|
|
const result = await compress(sharp(largeBuffer), {
|
|
targetSizeBytes: targetSize,
|
|
format: "jpg",
|
|
});
|
|
const buf = await result.toBuffer();
|
|
// Should be reasonably close to target (within 50% tolerance for small images)
|
|
expect(buf.length).toBeLessThan(largeBuffer.length);
|
|
});
|
|
});
|
|
|
|
describe("sharp format compatibility", () => {
|
|
it.each(SHARP_FORMAT_CASES)("convert accepts $sharpFormat", async ({
|
|
operationFormat,
|
|
expectedFormat,
|
|
}) => {
|
|
const result = await convert(tinyTestImage(), { format: operationFormat });
|
|
const buf = await result.toBuffer();
|
|
await expectOutputFormat(buf, expectedFormat);
|
|
});
|
|
|
|
it.each(SHARP_FORMAT_CASES)("compress accepts $sharpFormat", async ({
|
|
operationFormat,
|
|
expectedFormat,
|
|
}) => {
|
|
const result = await compress(tinyTestImage(), { quality: 80, format: operationFormat });
|
|
const buf = await result.toBuffer();
|
|
await expectOutputFormat(buf, expectedFormat);
|
|
});
|
|
});
|
|
|
|
describe("grayscale", () => {
|
|
it("should convert to grayscale", async () => {
|
|
const result = await grayscale(testImage());
|
|
const buf = await result.toBuffer();
|
|
const meta = await sharp(buf).metadata();
|
|
// Grayscale PNG may still report channels as 3 or 1 depending on output
|
|
expect(buf.length).toBeGreaterThan(0);
|
|
// The image should have no color variation
|
|
expect(meta.width).toBe(100);
|
|
});
|
|
});
|
|
|
|
describe("sepia", () => {
|
|
it("should apply sepia tone without error", async () => {
|
|
const result = await sepia(testImage());
|
|
const buf = await result.toBuffer();
|
|
expect(buf.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe("invert", () => {
|
|
it("should invert colors without error", async () => {
|
|
const result = await invert(testImage());
|
|
const buf = await result.toBuffer();
|
|
expect(buf.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe("brightness", () => {
|
|
it("should adjust brightness +50 without error", async () => {
|
|
const result = await brightness(testImage(), { value: 50 });
|
|
const buf = await result.toBuffer();
|
|
expect(buf.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("should throw on out-of-range value", async () => {
|
|
await expect(brightness(testImage(), { value: 150 })).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("contrast", () => {
|
|
it("should adjust contrast +50 without error", async () => {
|
|
const result = await contrast(testImage(), { value: 50 });
|
|
const buf = await result.toBuffer();
|
|
expect(buf.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("should throw on out-of-range value", async () => {
|
|
await expect(contrast(testImage(), { value: -150 })).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("saturation", () => {
|
|
it("should adjust saturation -50 without error", async () => {
|
|
const result = await saturation(testImage(), { value: -50 });
|
|
const buf = await result.toBuffer();
|
|
expect(buf.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("should throw on out-of-range value", async () => {
|
|
await expect(saturation(testImage(), { value: 200 })).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("colorChannels", () => {
|
|
it("should adjust color channels without error", async () => {
|
|
const result = await colorChannels(testImage(), {
|
|
red: 150,
|
|
green: 100,
|
|
blue: 50,
|
|
});
|
|
const buf = await result.toBuffer();
|
|
expect(buf.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("should throw on out-of-range values", async () => {
|
|
await expect(colorChannels(testImage(), { red: 250, green: 100, blue: 100 })).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("stripMetadata", () => {
|
|
it("should strip metadata without error", async () => {
|
|
const result = await stripMetadata(testImage());
|
|
const buf = await result.toBuffer();
|
|
expect(buf.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe("processImage (engine)", () => {
|
|
it("should apply multiple operations in sequence", async () => {
|
|
const result = await processImage(
|
|
testBuffer,
|
|
[
|
|
{ type: "resize", options: { width: 50, height: 50 } },
|
|
{ type: "grayscale", options: {} },
|
|
],
|
|
"png",
|
|
);
|
|
|
|
expect(result.info.width).toBe(50);
|
|
expect(result.info.height).toBe(50);
|
|
expect(result.buffer.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("should throw on unknown operation", async () => {
|
|
await expect(processImage(testBuffer, [{ type: "unknown-op", options: {} }])).rejects.toThrow(
|
|
"Unknown operation",
|
|
);
|
|
});
|
|
|
|
it("should convert output format", async () => {
|
|
const result = await processImage(testBuffer, [], "webp");
|
|
expect(result.info.format).toBe("webp");
|
|
});
|
|
});
|
|
|
|
describe("detectFormat", () => {
|
|
it("should detect PNG format", async () => {
|
|
const format = await detectFormat(testBuffer);
|
|
expect(format).toBe("png");
|
|
});
|
|
|
|
it("should detect JPEG format", async () => {
|
|
const jpegBuffer = await sharp(testBuffer).jpeg().toBuffer();
|
|
const format = await detectFormat(jpegBuffer);
|
|
expect(format).toBe("jpeg");
|
|
});
|
|
});
|
|
|
|
describe("getImageInfo", () => {
|
|
it("should return correct image info", async () => {
|
|
const info = await getImageInfo(testBuffer);
|
|
expect(info.width).toBe(100);
|
|
expect(info.height).toBe(100);
|
|
expect(info.format).toBe("png");
|
|
expect(info.channels).toBe(3);
|
|
expect(info.size).toBeGreaterThan(0);
|
|
expect(info.hasAlpha).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("mime utilities", () => {
|
|
it("should map extension to MIME type", () => {
|
|
expect(extToMime("jpg")).toBe("image/jpeg");
|
|
expect(extToMime("png")).toBe("image/png");
|
|
expect(extToMime("webp")).toBe("image/webp");
|
|
expect(extToMime(".jpg")).toBe("image/jpeg");
|
|
expect(extToMime("unknown")).toBe("application/octet-stream");
|
|
});
|
|
|
|
it("should map MIME type to extension", () => {
|
|
expect(mimeToExt("image/jpeg")).toBe("jpg");
|
|
expect(mimeToExt("image/png")).toBe("png");
|
|
expect(mimeToExt("application/unknown")).toBe("bin");
|
|
});
|
|
|
|
it("should map format to MIME type", () => {
|
|
expect(formatToMime("jpeg")).toBe("image/jpeg");
|
|
expect(formatToMime("png")).toBe("image/png");
|
|
});
|
|
|
|
it("should map format to extension", () => {
|
|
expect(formatToExt("jpeg")).toBe("jpg");
|
|
expect(formatToExt("png")).toBe("png");
|
|
});
|
|
});
|