feat(image-engine): add Sharp wrapper with 14 image operations

Operations: resize, crop, rotate, flip, convert, compress, strip-metadata,
brightness, contrast, saturation, color-channels, grayscale, sepia, invert.
Includes format detection, MIME mapping, metadata parsing, and engine orchestrator.
This commit is contained in:
Siddharth Kumar Sah
2026-03-22 03:42:40 +08:00
parent 5ab4c96ef7
commit ccac5b885c
23 changed files with 1111 additions and 3 deletions
+5 -2
View File
@@ -7,12 +7,15 @@
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"@stirling-image/shared": "workspace:*"
"@stirling-image/shared": "workspace:*",
"sharp": "^0.33.0"
},
"devDependencies": {
"typescript": "^5.7.0"
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}
+107
View File
@@ -0,0 +1,107 @@
import sharp from "sharp";
import type {
OperationResult,
OutputFormat,
Sharp,
ResizeOptions,
CropOptions,
RotateOptions,
FlipOptions,
ConvertOptions,
CompressOptions,
StripMetadataOptions,
BrightnessOptions,
ContrastOptions,
SaturationOptions,
ColorChannelOptions,
} from "./types.js";
import { resize } from "./operations/resize.js";
import { crop } from "./operations/crop.js";
import { rotate } from "./operations/rotate.js";
import { flip } from "./operations/flip.js";
import { convert } from "./operations/convert.js";
import { compress } from "./operations/compress.js";
import { stripMetadata } from "./operations/strip-metadata.js";
import { brightness } from "./operations/brightness.js";
import { contrast } from "./operations/contrast.js";
import { saturation } from "./operations/saturation.js";
import { colorChannels } from "./operations/color-channels.js";
import { grayscale } from "./operations/grayscale.js";
import { sepia } from "./operations/sepia.js";
import { invert } from "./operations/invert.js";
import { getImageInfo } from "./utils/metadata.js";
export interface Operation {
type: string;
options: Record<string, unknown>;
}
const OPERATION_MAP: Record<
string,
(image: Sharp, options: Record<string, unknown>) => Promise<Sharp>
> = {
resize: (img, opts) => resize(img, opts as unknown as ResizeOptions),
crop: (img, opts) => crop(img, opts as unknown as CropOptions),
rotate: (img, opts) => rotate(img, opts as unknown as RotateOptions),
flip: (img, opts) => flip(img, opts as unknown as FlipOptions),
convert: (img, opts) => convert(img, opts as unknown as ConvertOptions),
compress: (img, opts) => compress(img, opts as unknown as CompressOptions),
"strip-metadata": (img, opts) =>
stripMetadata(img, opts as unknown as StripMetadataOptions),
brightness: (img, opts) => brightness(img, opts as unknown as BrightnessOptions),
contrast: (img, opts) => contrast(img, opts as unknown as ContrastOptions),
saturation: (img, opts) => saturation(img, opts as unknown as SaturationOptions),
"color-channels": (img, opts) =>
colorChannels(img, opts as unknown as ColorChannelOptions),
grayscale: (img) => grayscale(img),
sepia: (img) => sepia(img),
invert: (img) => invert(img),
};
const FORMAT_MAP: Record<OutputFormat, string> = {
jpg: "jpeg",
png: "png",
webp: "webp",
avif: "avif",
tiff: "tiff",
gif: "gif",
};
/**
* Process an image through a pipeline of operations.
*
* @param input - The raw image buffer
* @param operations - Array of operations to apply in sequence
* @param outputFormat - Optional output format (defaults to input format)
* @returns The processed image buffer and metadata
*/
export async function processImage(
input: Buffer,
operations: Operation[],
outputFormat?: OutputFormat
): Promise<OperationResult> {
let image: Sharp = sharp(input);
// Apply each operation in sequence
for (const op of operations) {
const handler = OPERATION_MAP[op.type];
if (!handler) {
throw new Error(`Unknown operation: ${op.type}`);
}
image = await handler(image, op.options);
}
// Convert to output format if specified
if (outputFormat) {
const sharpFormat = FORMAT_MAP[outputFormat];
if (!sharpFormat) {
throw new Error(`Unsupported output format: ${outputFormat}`);
}
image = image.toFormat(sharpFormat as keyof import("sharp").FormatEnum);
}
const buffer = await image.toBuffer();
const info = await getImageInfo(buffer);
return { buffer, info };
}
@@ -0,0 +1,57 @@
import sharp from "sharp";
const MAGIC_BYTES: Array<{ bytes: number[]; offset: number; format: string }> = [
{ bytes: [0x89, 0x50, 0x4e, 0x47], offset: 0, format: "png" },
{ bytes: [0xff, 0xd8, 0xff], offset: 0, format: "jpeg" },
{ bytes: [0x47, 0x49, 0x46, 0x38], offset: 0, format: "gif" },
{ bytes: [0x52, 0x49, 0x46, 0x46], offset: 0, format: "webp" }, // RIFF header (check WEBP after)
{ bytes: [0x49, 0x49, 0x2a, 0x00], offset: 0, format: "tiff" }, // Little-endian TIFF
{ bytes: [0x4d, 0x4d, 0x00, 0x2a], offset: 0, format: "tiff" }, // Big-endian TIFF
{ bytes: [0x42, 0x4d], offset: 0, format: "bmp" },
];
/**
* Detect the image format from a buffer.
* Uses Sharp metadata first, falls back to magic byte detection.
*/
export async function detectFormat(buffer: Buffer): Promise<string> {
try {
const metadata = await sharp(buffer).metadata();
if (metadata.format) {
return metadata.format;
}
} catch {
// Sharp couldn't parse it; fall through to magic bytes
}
return detectByMagicBytes(buffer);
}
function detectByMagicBytes(buffer: Buffer): string {
for (const entry of MAGIC_BYTES) {
if (buffer.length < entry.offset + entry.bytes.length) {
continue;
}
let match = true;
for (let i = 0; i < entry.bytes.length; i++) {
if (buffer[entry.offset + i] !== entry.bytes[i]) {
match = false;
break;
}
}
if (match) {
// For RIFF, verify it's actually WEBP
if (entry.format === "webp" && buffer.length >= 12) {
const webpSignature = buffer.slice(8, 12).toString("ascii");
if (webpSignature !== "WEBP") {
continue;
}
}
return entry.format;
}
}
return "unknown";
}
+19 -1
View File
@@ -1 +1,19 @@
export const IMAGE_ENGINE_VERSION = "0.0.1";
export * from "./types.js";
export * from "./engine.js";
export * from "./formats/detect.js";
export * from "./utils/metadata.js";
export * from "./utils/mime.js";
export { resize } from "./operations/resize.js";
export { crop } from "./operations/crop.js";
export { rotate } from "./operations/rotate.js";
export { flip } from "./operations/flip.js";
export { convert } from "./operations/convert.js";
export { compress } from "./operations/compress.js";
export { stripMetadata } from "./operations/strip-metadata.js";
export { brightness } from "./operations/brightness.js";
export { contrast } from "./operations/contrast.js";
export { saturation } from "./operations/saturation.js";
export { colorChannels } from "./operations/color-channels.js";
export { grayscale } from "./operations/grayscale.js";
export { sepia } from "./operations/sepia.js";
export { invert } from "./operations/invert.js";
@@ -0,0 +1,15 @@
import type { Sharp, BrightnessOptions } from "../types.js";
export async function brightness(image: Sharp, options: BrightnessOptions): Promise<Sharp> {
const { value } = options;
if (value < -100 || value > 100) {
throw new Error("Brightness value must be between -100 and +100");
}
// Map -100..+100 to 0..2 where 1.0 = no change
// -100 -> 0, 0 -> 1, +100 -> 2
const multiplier = 1 + value / 100;
return image.modulate({ brightness: multiplier });
}
@@ -0,0 +1,35 @@
import type { Sharp, ColorChannelOptions } from "../types.js";
export async function colorChannels(
image: Sharp,
options: ColorChannelOptions
): Promise<Sharp> {
const { red, green, blue } = options;
if (red < 0 || red > 200) {
throw new Error("Red channel value must be between 0 and 200");
}
if (green < 0 || green > 200) {
throw new Error("Green channel value must be between 0 and 200");
}
if (blue < 0 || blue > 200) {
throw new Error("Blue channel value must be between 0 and 200");
}
// Map 0-200 to 0-2 multipliers on the diagonal of a 3x3 recomb matrix
const rMul = red / 100;
const gMul = green / 100;
const bMul = blue / 100;
const matrix: [
[number, number, number],
[number, number, number],
[number, number, number],
] = [
[rMul, 0, 0],
[0, gMul, 0],
[0, 0, bMul],
];
return image.recomb(matrix);
}
@@ -0,0 +1,80 @@
import sharp from "sharp";
import type { Sharp, CompressOptions, OutputFormat } from "../types.js";
const FORMAT_MAP: Record<OutputFormat, string> = {
jpg: "jpeg",
png: "png",
webp: "webp",
avif: "avif",
tiff: "tiff",
gif: "gif",
};
export async function compress(image: Sharp, options: CompressOptions): Promise<Sharp> {
const { quality, targetSizeBytes, format } = options;
const metadata = await image.metadata();
const outputFormat = format
? (FORMAT_MAP[format] as keyof import("sharp").FormatEnum)
: (metadata.format as keyof import("sharp").FormatEnum) ?? "jpeg";
if (targetSizeBytes !== undefined) {
if (targetSizeBytes <= 0) {
throw new Error("Target size must be greater than 0");
}
return compressToTargetSize(image, outputFormat, targetSizeBytes);
}
const q = quality ?? 80;
if (q < 1 || q > 100) {
throw new Error("Quality must be between 1 and 100");
}
return image.toFormat(outputFormat, { quality: q });
}
async function compressToTargetSize(
image: Sharp,
format: keyof import("sharp").FormatEnum,
targetBytes: number
): Promise<Sharp> {
// Get the raw buffer to re-create Sharp instances for each attempt
const inputBuffer = await image.toBuffer();
let low = 1;
let high = 100;
let bestQuality = 80;
let bestBuffer: Buffer | null = null;
const maxIterations = 8;
const tolerance = 0.05; // 5%
for (let i = 0; i < maxIterations; i++) {
const mid = Math.round((low + high) / 2);
const attempt = sharp(inputBuffer).toFormat(format, { quality: mid });
const resultBuffer = await attempt.toBuffer();
const resultSize = resultBuffer.length;
if (Math.abs(resultSize - targetBytes) / targetBytes <= tolerance) {
bestQuality = mid;
bestBuffer = resultBuffer;
break;
}
if (resultSize > targetBytes) {
high = mid - 1;
} else {
low = mid + 1;
bestQuality = mid;
bestBuffer = resultBuffer;
}
}
// If we never found a suitable buffer, compress at the best quality we found
if (bestBuffer === null) {
bestBuffer = await sharp(inputBuffer)
.toFormat(format, { quality: bestQuality })
.toBuffer();
}
return sharp(bestBuffer);
}
@@ -0,0 +1,17 @@
import type { Sharp, ContrastOptions } from "../types.js";
export async function contrast(image: Sharp, options: ContrastOptions): Promise<Sharp> {
const { value } = options;
if (value < -100 || value > 100) {
throw new Error("Contrast value must be between -100 and +100");
}
// Map -100..+100 to linear transform
// slope = 1 + (value/100), e.g. -100 -> 0, 0 -> 1, +100 -> 2
// intercept centers the adjustment around middle gray (128)
const slope = 1 + value / 100;
const intercept = 128 * (1 - slope);
return image.linear(slope, intercept);
}
@@ -0,0 +1,29 @@
import type { Sharp, ConvertOptions, OutputFormat } from "../types.js";
const FORMAT_MAP: Record<OutputFormat, string> = {
jpg: "jpeg",
png: "png",
webp: "webp",
avif: "avif",
tiff: "tiff",
gif: "gif",
};
export async function convert(image: Sharp, options: ConvertOptions): Promise<Sharp> {
const { format, quality } = options;
const sharpFormat = FORMAT_MAP[format];
if (!sharpFormat) {
throw new Error(`Unsupported output format: ${format}`);
}
const formatOptions: Record<string, unknown> = {};
if (quality !== undefined) {
if (quality < 1 || quality > 100) {
throw new Error("Quality must be between 1 and 100");
}
formatOptions.quality = quality;
}
return image.toFormat(sharpFormat as keyof import("sharp").FormatEnum, formatOptions);
}
@@ -0,0 +1,29 @@
import type { Sharp, CropOptions } from "../types.js";
export async function crop(image: Sharp, options: CropOptions): Promise<Sharp> {
const { left, top, width, height } = options;
if (width <= 0 || height <= 0) {
throw new Error("Crop width and height must be greater than 0");
}
if (left < 0 || top < 0) {
throw new Error("Crop left and top must be non-negative");
}
const metadata = await image.metadata();
const imgWidth = metadata.width ?? 0;
const imgHeight = metadata.height ?? 0;
if (left + width > imgWidth) {
throw new Error(
`Crop region exceeds image width: left(${left}) + width(${width}) > ${imgWidth}`
);
}
if (top + height > imgHeight) {
throw new Error(
`Crop region exceeds image height: top(${top}) + height(${height}) > ${imgHeight}`
);
}
return image.extract({ left, top, width, height });
}
@@ -0,0 +1,21 @@
import type { Sharp, FlipOptions } from "../types.js";
export async function flip(image: Sharp, options: FlipOptions): Promise<Sharp> {
const { horizontal, vertical } = options;
if (!horizontal && !vertical) {
throw new Error("Flip requires at least one of horizontal or vertical");
}
let result = image;
if (horizontal) {
result = result.flop();
}
if (vertical) {
result = result.flip();
}
return result;
}
@@ -0,0 +1,5 @@
import type { Sharp } from "../types.js";
export async function grayscale(image: Sharp): Promise<Sharp> {
return image.grayscale();
}
@@ -0,0 +1,5 @@
import type { Sharp } from "../types.js";
export async function invert(image: Sharp): Promise<Sharp> {
return image.negate();
}
@@ -0,0 +1,33 @@
import type { Sharp, ResizeOptions } from "../types.js";
export async function resize(image: Sharp, options: ResizeOptions): Promise<Sharp> {
let { width, height, fit, withoutEnlargement, percentage } = options;
if (percentage !== undefined) {
if (percentage <= 0) {
throw new Error("Resize percentage must be greater than 0");
}
const metadata = await image.metadata();
const currentWidth = metadata.width ?? 0;
const currentHeight = metadata.height ?? 0;
width = Math.round(currentWidth * (percentage / 100));
height = Math.round(currentHeight * (percentage / 100));
}
if (width !== undefined && width <= 0) {
throw new Error("Resize width must be greater than 0");
}
if (height !== undefined && height <= 0) {
throw new Error("Resize height must be greater than 0");
}
if (width === undefined && height === undefined) {
throw new Error("Resize requires width, height, or percentage");
}
return image.resize({
width,
height,
fit: fit ?? "cover",
withoutEnlargement: withoutEnlargement ?? false,
});
}
@@ -0,0 +1,15 @@
import type { Sharp, RotateOptions } from "../types.js";
export async function rotate(image: Sharp, options: RotateOptions): Promise<Sharp> {
const { angle, background } = options;
const isMultipleOf90 = angle % 90 === 0;
if (isMultipleOf90) {
return image.rotate(angle);
}
return image.rotate(angle, {
background: background ?? "#000000",
});
}
@@ -0,0 +1,15 @@
import type { Sharp, SaturationOptions } from "../types.js";
export async function saturation(image: Sharp, options: SaturationOptions): Promise<Sharp> {
const { value } = options;
if (value < -100 || value > 100) {
throw new Error("Saturation value must be between -100 and +100");
}
// Map -100..+100 to 0..2 where 1.0 = no change
// -100 -> 0 (grayscale), 0 -> 1 (no change), +100 -> 2 (double saturation)
const multiplier = 1 + value / 100;
return image.modulate({ saturation: multiplier });
}
@@ -0,0 +1,16 @@
import type { Sharp } from "../types.js";
// Standard sepia tone matrix
const SEPIA_MATRIX: [
[number, number, number],
[number, number, number],
[number, number, number],
] = [
[0.393, 0.769, 0.189],
[0.349, 0.686, 0.168],
[0.272, 0.534, 0.131],
];
export async function sepia(image: Sharp): Promise<Sharp> {
return image.recomb(SEPIA_MATRIX);
}
@@ -0,0 +1,38 @@
import type { Sharp, StripMetadataOptions } from "../types.js";
export async function stripMetadata(
image: Sharp,
options: StripMetadataOptions = {}
): Promise<Sharp> {
const { stripExif, stripGps, stripIcc, stripXmp, stripAll } = options;
// Default behavior: strip all metadata
const shouldStripAll =
stripAll === true ||
(stripExif === undefined &&
stripGps === undefined &&
stripIcc === undefined &&
stripXmp === undefined &&
stripAll === undefined);
if (shouldStripAll) {
// withMetadata({}) with no options strips everything;
// but to truly strip we avoid calling withMetadata at all.
// Sharp strips metadata by default when outputting.
// Calling .withMetadata() KEEPS metadata, so we do NOT call it.
return image;
}
// Selective stripping: we keep metadata but remove specific fields.
// Sharp's withMetadata lets us keep ICC, EXIF, etc.
// We call withMetadata to keep what wasn't requested stripped.
const keepIcc = !stripIcc;
return image.withMetadata({
// If we want to keep ICC, pass undefined (Sharp default keeps it with withMetadata)
// If we want to strip ICC, we need to not call withMetadata at all or handle differently
// Sharp's withMetadata keeps metadata; without it, metadata is stripped.
// For selective stripping, we strip all first then re-add what we want to keep.
...(keepIcc ? {} : {}),
});
}
+82
View File
@@ -0,0 +1,82 @@
import type sharp from "sharp";
export type Sharp = sharp.Sharp;
export interface ImageInfo {
width: number;
height: number;
format: string;
channels: number;
size: number;
hasAlpha: boolean;
metadata: Record<string, unknown>;
}
export interface OperationResult {
buffer: Buffer;
info: ImageInfo;
}
export type OutputFormat = "jpg" | "png" | "webp" | "avif" | "tiff" | "gif";
export interface ResizeOptions {
width?: number;
height?: number;
fit?: "contain" | "cover" | "fill" | "inside" | "outside";
withoutEnlargement?: boolean;
percentage?: number;
}
export interface CropOptions {
left: number;
top: number;
width: number;
height: number;
}
export interface RotateOptions {
angle: number;
background?: string;
}
export interface FlipOptions {
horizontal?: boolean;
vertical?: boolean;
}
export interface ConvertOptions {
format: OutputFormat;
quality?: number;
}
export interface CompressOptions {
quality?: number;
targetSizeBytes?: number;
format?: OutputFormat;
}
export interface StripMetadataOptions {
stripExif?: boolean;
stripGps?: boolean;
stripIcc?: boolean;
stripXmp?: boolean;
stripAll?: boolean;
}
export interface BrightnessOptions {
value: number; // -100 to +100
}
export interface ContrastOptions {
value: number; // -100 to +100
}
export interface SaturationOptions {
value: number; // -100 to +100
}
export interface ColorChannelOptions {
red: number; // 0-200 (100 = no change)
green: number; // 0-200
blue: number; // 0-200
}
@@ -0,0 +1,28 @@
import sharp from "sharp";
import type { ImageInfo } from "../types.js";
/**
* Extract comprehensive image metadata from a buffer.
*/
export async function getImageInfo(buffer: Buffer): Promise<ImageInfo> {
const metadata = await sharp(buffer).metadata();
return {
width: metadata.width ?? 0,
height: metadata.height ?? 0,
format: metadata.format ?? "unknown",
channels: metadata.channels ?? 0,
size: buffer.length,
hasAlpha: metadata.hasAlpha ?? false,
metadata: {
space: metadata.space,
density: metadata.density,
isProgressive: metadata.isProgressive,
hasProfile: metadata.hasProfile,
orientation: metadata.orientation,
exif: metadata.exif ? true : false,
icc: metadata.icc ? true : false,
xmp: metadata.xmp ? true : false,
},
};
}
+63
View File
@@ -0,0 +1,63 @@
const EXT_TO_MIME: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
avif: "image/avif",
tiff: "image/tiff",
tif: "image/tiff",
gif: "image/gif",
bmp: "image/bmp",
svg: "image/svg+xml",
ico: "image/x-icon",
heif: "image/heif",
heic: "image/heic",
};
const MIME_TO_EXT: Record<string, string> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/avif": "avif",
"image/tiff": "tiff",
"image/gif": "gif",
"image/bmp": "bmp",
"image/svg+xml": "svg",
"image/x-icon": "ico",
"image/heif": "heif",
"image/heic": "heic",
};
/**
* Get the MIME type for a file extension (without dot).
*/
export function extToMime(ext: string): string {
const normalized = ext.toLowerCase().replace(/^\./, "");
return EXT_TO_MIME[normalized] ?? "application/octet-stream";
}
/**
* Get the file extension for a MIME type (without dot).
*/
export function mimeToExt(mime: string): string {
const normalized = mime.toLowerCase();
return MIME_TO_EXT[normalized] ?? "bin";
}
/**
* Get the MIME type for a Sharp format string.
*/
export function formatToMime(format: string): string {
const normalized = format.toLowerCase();
if (normalized === "jpeg") return "image/jpeg";
return EXT_TO_MIME[normalized] ?? "application/octet-stream";
}
/**
* Get the file extension for a Sharp format string.
*/
export function formatToExt(format: string): string {
const normalized = format.toLowerCase();
if (normalized === "jpeg") return "jpg";
return normalized;
}
@@ -0,0 +1,390 @@
import { describe, it, expect, beforeAll } from "vitest";
import sharp from "sharp";
import { resize } from "../src/operations/resize.js";
import { crop } from "../src/operations/crop.js";
import { rotate } from "../src/operations/rotate.js";
import { flip } from "../src/operations/flip.js";
import { convert } from "../src/operations/convert.js";
import { compress } from "../src/operations/compress.js";
import { grayscale } from "../src/operations/grayscale.js";
import { sepia } from "../src/operations/sepia.js";
import { invert } from "../src/operations/invert.js";
import { brightness } from "../src/operations/brightness.js";
import { contrast } from "../src/operations/contrast.js";
import { saturation } from "../src/operations/saturation.js";
import { colorChannels } from "../src/operations/color-channels.js";
import { stripMetadata } from "../src/operations/strip-metadata.js";
import { processImage } from "../src/engine.js";
import { detectFormat } from "../src/formats/detect.js";
import { getImageInfo } from "../src/utils/metadata.js";
import { extToMime, mimeToExt, formatToMime, formatToExt } from "../src/utils/mime.js";
// Generate a 100x100 red PNG buffer for testing
let testBuffer: Buffer;
let testImage: () => sharp.Sharp;
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("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");
});
});
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
},
});