Initial Commit
This commit is contained in:
65
packages/media/image/compress-image/README.md
Normal file
65
packages/media/image/compress-image/README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# @suaveplan/compress-image - Runtime-agnostic image compression utilities: resize, quality reduction, format conversion, and metadata stripping via pluggable adapters
|
||||
|
||||
Runtime-agnostic image compression utilities with pluggable adapters for Node.js (Sharp) and testing (MemoryAdapter). Supports resize, quality reduction, format conversion (JPEG, PNG, WebP, AVIF), and metadata stripping. The compressor core is pure TypeScript with zero runtime dependencies; only the Sharp adapter requires the native `sharp` library.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [sharp](./src/adapters/sharp/sharp.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [sharp](./src/adapters/sharp/sharp.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [compressor](./src/core/compressor/compressor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [compressor](./src/core/compressor/compressor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/compress-image-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [compress-image-error](./src/errors/compress-image-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schemas](./src/schemas/compress-options.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [compress-options.schema](./src/schemas/compress-options.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [utils](./src/utils/image-utils.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [image-utils](./src/utils/image-utils.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/compress-image
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { createCompressor } from "@suaveplan/compress-image/compressor";
|
||||
import { SharpAdapter } from "@suaveplan/compress-image/adapters/sharp";
|
||||
import sharp from "sharp";
|
||||
|
||||
const compressor = createCompressor(new SharpAdapter(sharp));
|
||||
|
||||
const result = await compressor.compress(imageBuffer, {
|
||||
format: "webp",
|
||||
quality: 75,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
resizeMode: "cover",
|
||||
stripMetadata: true,
|
||||
});
|
||||
|
||||
console.log(result.compressionRatio, result.compressedSize);
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/core/compressor/compressor.md`](./src/core/compressor/compressor.md) — `ImageCompressor` core and `createCompressor`
|
||||
- [`src/adapters/memory/memory.md`](./src/adapters/memory/memory.md) — `MemoryAdapter` for testing without native deps
|
||||
- [`src/adapters/sharp/sharp.md`](./src/adapters/sharp/sharp.md) — `SharpAdapter` for Node.js production compression
|
||||
- [`src/errors/compress-image-error.md`](./src/errors/compress-image-error.md) — `CompressImageError` and error codes
|
||||
- [`src/schemas/compress-options.schema.md`](./src/schemas/compress-options.schema.md) — Zod schemas for compression options
|
||||
- [`src/types/compress-image.types.md`](./src/types/compress-image.types.md) — TypeScript interfaces for results and adapters
|
||||
- [`src/utils/image-utils.md`](./src/utils/image-utils.md) — Pure TypeScript image utilities
|
||||
|
||||
## Subpath Exports
|
||||
|
||||
- `.` — All public types, errors, utilities, adapters, and the compressor
|
||||
- `./compressor` — `ImageCompressor`, `createCompressor`
|
||||
- `./adapters/memory` — `MemoryAdapter`
|
||||
- `./adapters/sharp` — `SharpAdapter`, `SharpLike`, `SharpInstance`
|
||||
- `./errors` — `CompressImageError`, `COMPRESS_IMAGE_CODES`
|
||||
102
packages/media/image/compress-image/package.json
Normal file
102
packages/media/image/compress-image/package.json
Normal file
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"name": "@suaveplan/compress-image",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Runtime-agnostic image compression utilities: resize, quality reduction, format conversion, and metadata stripping via pluggable adapters",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./adapters/memory": {
|
||||
"import": {
|
||||
"types": "./dist/adapters/memory/memory.d.ts",
|
||||
"default": "./dist/adapters/memory/memory.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/adapters/memory/memory.d.ts",
|
||||
"default": "./dist/adapters/memory/memory.js"
|
||||
}
|
||||
},
|
||||
"./adapters/sharp": {
|
||||
"import": {
|
||||
"types": "./dist/adapters/sharp/sharp.d.ts",
|
||||
"default": "./dist/adapters/sharp/sharp.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/adapters/sharp/sharp.d.ts",
|
||||
"default": "./dist/adapters/sharp/sharp.js"
|
||||
}
|
||||
},
|
||||
"./compressor": {
|
||||
"import": {
|
||||
"types": "./dist/core/compressor/compressor.d.ts",
|
||||
"default": "./dist/core/compressor/compressor.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/compressor/compressor.d.ts",
|
||||
"default": "./dist/core/compressor/compressor.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/compress-image-error.d.ts",
|
||||
"default": "./dist/errors/compress-image-error.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/compress-image-error.d.ts",
|
||||
"default": "./dist/errors/compress-image-error.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { MemoryAdapter } from "./memory";
|
||||
export { SharpAdapter, type SharpInstance, type SharpLike } from "./sharp";
|
||||
@@ -0,0 +1 @@
|
||||
export { MemoryAdapter } from "./memory";
|
||||
@@ -0,0 +1,81 @@
|
||||
# memory-adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides a pure-TypeScript implementation of `ImageAdapter` that operates entirely on raw bytes — no native addons, no WebAssembly, no external codec dependencies. Its primary purpose is to enable 100% test coverage of the `ImageCompressor` class, Zod validation paths, and OTel instrumentation without requiring Sharp or any platform-specific library.
|
||||
|
||||
The adapter parses PNG IHDR chunks and JPEG SOF markers to derive real pixel dimensions from test fixtures, and synthesises `CompressResult` metadata (including a simulated `compressionRatio`) from the requested `quality` option.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements the full `ImageAdapter` interface (`compress`, `getInfo`, `resize`)
|
||||
- PNG dimension extraction via IHDR chunk parsing
|
||||
- JPEG dimension extraction via SOF0/SOF2 marker scanning
|
||||
- Synthetic `compressionRatio` computation: `ratio = 100 / quality`
|
||||
- Conservative alpha detection: PNG and WebP are flagged as potentially having alpha
|
||||
- Throws `CompressImageError` with code `UNSUPPORTED_FORMAT` for unrecognized inputs
|
||||
- Zero external runtime dependencies
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MemoryAdapter } from '@suaveplan/compress-image/adapters/memory';
|
||||
import { createCompressor } from '@suaveplan/compress-image/compressor';
|
||||
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
const result = await compressor.compress(pngBuffer, { quality: 60, format: 'webp' });
|
||||
console.log(result.compressionRatio); // ~1.67
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MemoryAdapter } from '@suaveplan/compress-image/adapters/memory';
|
||||
|
||||
const adapter = new MemoryAdapter();
|
||||
|
||||
// getInfo: detect format and dimensions
|
||||
const info = await adapter.getInfo(pngBuffer);
|
||||
// { width: 1, height: 1, format: 'png', size: 68, hasAlpha: true }
|
||||
|
||||
// compress: returns original bytes with synthetic metadata
|
||||
const result = await adapter.compress(pngBuffer, {
|
||||
quality: 50,
|
||||
format: 'webp',
|
||||
width: 200,
|
||||
height: 200,
|
||||
});
|
||||
// result.compressionRatio === 2.0
|
||||
// result.data === pngBuffer (no actual re-encoding)
|
||||
|
||||
// resize: passthrough (no real pixel manipulation)
|
||||
const resized = await adapter.resize(pngBuffer, 100, 100, 'cover');
|
||||
// resized === pngBuffer
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class MemoryAdapter implements ImageAdapter`
|
||||
|
||||
#### `compress(input: Uint8Array, options: CompressOptions): Promise<CompressResult>`
|
||||
|
||||
Returns a `CompressResult` where `data` is identical to `input`. The `format` defaults to the detected input format. `compressionRatio` is computed as `100 / quality` (quality defaults to 80, giving ratio 1.25). `width` and `height` in the result reflect `options.width`/`options.height` when supplied, otherwise the parsed dimensions. Throws `CompressImageError(UNSUPPORTED_FORMAT)` for unrecognized input.
|
||||
|
||||
#### `getInfo(input: Uint8Array): Promise<ImageInfo>`
|
||||
|
||||
Returns `{ width, height, format, size, hasAlpha }` derived from magic-byte parsing. Alpha is `true` for PNG and WebP, `false` for JPEG. AVIF and unrecognized formats fall back to `{ width: 1, height: 1 }`. Throws `CompressImageError(UNSUPPORTED_FORMAT)` if no format is detected.
|
||||
|
||||
#### `resize(input: Uint8Array, width: number, height: number, mode?: ResizeMode): Promise<Uint8Array>`
|
||||
|
||||
Returns `input` unchanged. Validates that the format is recognizable before returning; throws `CompressImageError(UNSUPPORTED_FORMAT)` otherwise.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The simulated `compressionRatio = 100 / quality` formula is a deliberate simplification. Real compressors produce ratios that depend on image content complexity, format, and encoder state. The formula satisfies the test contract that "higher quality = lower ratio", while being deterministic and dependency-free.
|
||||
|
||||
`compressedSize` is `Math.max(1, Math.round(originalSize / compressionRatio))` — the `Math.max(1, ...)` guard prevents a zero byte count when the input buffer is a single byte at quality 100.
|
||||
|
||||
WebP and AVIF dimension parsing is not implemented because these formats require a full codec to parse the container headers. The fallback `{ width: 1, height: 1 }` is clearly documented so test authors set explicit `width`/`height` options when asserting on those fields.
|
||||
@@ -0,0 +1,284 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CompressImageError } from "../../errors/compress-image-error.js";
|
||||
import { MemoryAdapter } from "./memory.js";
|
||||
|
||||
// ── Test fixtures ─────────────────────────────────────────────────────────────
|
||||
|
||||
const MINIMAL_PNG = new Uint8Array([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
|
||||
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde,
|
||||
]);
|
||||
|
||||
const MINIMAL_JPEG = new Uint8Array([
|
||||
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01,
|
||||
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xc0, 0x00, 0x0b,
|
||||
0x08, 0x00, 0x10, 0x00, 0x10, 0x01,
|
||||
]);
|
||||
|
||||
const MINIMAL_WEBP = new Uint8Array([
|
||||
0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50,
|
||||
]);
|
||||
|
||||
const UNKNOWN = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0x04]);
|
||||
|
||||
// ── compress ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryAdapter.compress", () => {
|
||||
const adapter = new MemoryAdapter();
|
||||
|
||||
it("returns original data unchanged for PNG", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const result = await adapter.compress(MINIMAL_PNG, {});
|
||||
expect(result.data).toBe(MINIMAL_PNG);
|
||||
});
|
||||
|
||||
it("returns correct format from options.format", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const result = await adapter.compress(MINIMAL_PNG, { format: "webp" });
|
||||
expect(result.format).toBe("webp");
|
||||
});
|
||||
|
||||
it("falls back to detected format when options.format is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const result = await adapter.compress(MINIMAL_JPEG, {});
|
||||
expect(result.format).toBe("jpeg");
|
||||
});
|
||||
|
||||
it("computes compressionRatio as 100/quality", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const result = await adapter.compress(MINIMAL_PNG, { quality: 50 });
|
||||
expect(result.compressionRatio).toBe(2);
|
||||
});
|
||||
|
||||
it("uses default quality 80 when not specified", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const result = await adapter.compress(MINIMAL_PNG, {});
|
||||
expect(result.compressionRatio).toBeCloseTo(1.25, 5);
|
||||
});
|
||||
|
||||
it("uses options.width for result width", { timeout: 5000 }, async () => {
|
||||
const result = await adapter.compress(MINIMAL_PNG, { width: 200 });
|
||||
expect(result.width).toBe(200);
|
||||
});
|
||||
|
||||
it("uses options.height for result height", { timeout: 5000 }, async () => {
|
||||
const result = await adapter.compress(MINIMAL_PNG, { height: 300 });
|
||||
expect(result.height).toBe(300);
|
||||
});
|
||||
|
||||
it("populates originalSize from input.length", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const result = await adapter.compress(MINIMAL_PNG, {});
|
||||
expect(result.originalSize).toBe(MINIMAL_PNG.length);
|
||||
});
|
||||
|
||||
it("compressedSize is rounded down from originalSize/ratio", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const result = await adapter.compress(MINIMAL_PNG, { quality: 80 });
|
||||
expect(result.compressedSize).toBe(
|
||||
Math.max(1, Math.round(MINIMAL_PNG.length / (100 / 80)))
|
||||
);
|
||||
});
|
||||
|
||||
it("compressedSize is at least 1 byte", { timeout: 5000 }, async () => {
|
||||
const singleByte = new Uint8Array([
|
||||
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00,
|
||||
0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xc0,
|
||||
0x00, 0x0b, 0x08, 0x00, 0x10, 0x00, 0x10, 0x01,
|
||||
]);
|
||||
const result = await adapter.compress(singleByte, { quality: 100 });
|
||||
expect(result.compressedSize).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("parses PNG dimensions from IHDR", { timeout: 5000 }, async () => {
|
||||
const result = await adapter.compress(MINIMAL_PNG, {});
|
||||
expect(result.width).toBe(1);
|
||||
expect(result.height).toBe(1);
|
||||
});
|
||||
|
||||
it("parses JPEG dimensions from SOF0", { timeout: 5000 }, async () => {
|
||||
const result = await adapter.compress(MINIMAL_JPEG, {});
|
||||
expect(result.width).toBe(16);
|
||||
expect(result.height).toBe(16);
|
||||
});
|
||||
|
||||
it("falls back to 1x1 for WebP (no decoder)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const result = await adapter.compress(MINIMAL_WEBP, {});
|
||||
expect(result.width).toBe(1);
|
||||
expect(result.height).toBe(1);
|
||||
});
|
||||
|
||||
it("throws CompressImageError for unknown format", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(adapter.compress(UNKNOWN, {})).rejects.toThrow(
|
||||
CompressImageError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getInfo ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryAdapter.getInfo", () => {
|
||||
const adapter = new MemoryAdapter();
|
||||
|
||||
it("returns png format for PNG buffer", { timeout: 5000 }, async () => {
|
||||
const info = await adapter.getInfo(MINIMAL_PNG);
|
||||
expect(info.format).toBe("png");
|
||||
});
|
||||
|
||||
it("returns jpeg format for JPEG buffer", { timeout: 5000 }, async () => {
|
||||
const info = await adapter.getInfo(MINIMAL_JPEG);
|
||||
expect(info.format).toBe("jpeg");
|
||||
});
|
||||
|
||||
it("returns webp format for WebP buffer", { timeout: 5000 }, async () => {
|
||||
const info = await adapter.getInfo(MINIMAL_WEBP);
|
||||
expect(info.format).toBe("webp");
|
||||
});
|
||||
|
||||
it("returns correct size", { timeout: 5000 }, async () => {
|
||||
const info = await adapter.getInfo(MINIMAL_PNG);
|
||||
expect(info.size).toBe(MINIMAL_PNG.length);
|
||||
});
|
||||
|
||||
it("reports hasAlpha true for PNG", { timeout: 5000 }, async () => {
|
||||
const info = await adapter.getInfo(MINIMAL_PNG);
|
||||
expect(info.hasAlpha).toBe(true);
|
||||
});
|
||||
|
||||
it("reports hasAlpha false for JPEG", { timeout: 5000 }, async () => {
|
||||
const info = await adapter.getInfo(MINIMAL_JPEG);
|
||||
expect(info.hasAlpha).toBe(false);
|
||||
});
|
||||
|
||||
it("reports hasAlpha true for WebP", { timeout: 5000 }, async () => {
|
||||
const info = await adapter.getInfo(MINIMAL_WEBP);
|
||||
expect(info.hasAlpha).toBe(true);
|
||||
});
|
||||
|
||||
it("parses 1x1 dimensions from PNG", { timeout: 5000 }, async () => {
|
||||
const info = await adapter.getInfo(MINIMAL_PNG);
|
||||
expect(info.width).toBe(1);
|
||||
expect(info.height).toBe(1);
|
||||
});
|
||||
|
||||
it("throws CompressImageError for unknown format", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(adapter.getInfo(UNKNOWN)).rejects.toThrow(
|
||||
CompressImageError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── resize ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryAdapter.resize", () => {
|
||||
const adapter = new MemoryAdapter();
|
||||
|
||||
it("returns the original buffer unchanged for PNG", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const result = await adapter.resize(MINIMAL_PNG, 100, 200);
|
||||
expect(result).toBe(MINIMAL_PNG);
|
||||
});
|
||||
|
||||
it("returns the original buffer unchanged for JPEG", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const result = await adapter.resize(MINIMAL_JPEG, 50, 50, "contain");
|
||||
expect(result).toBe(MINIMAL_JPEG);
|
||||
});
|
||||
|
||||
it("accepts all resize modes without error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
for (const mode of [
|
||||
"cover",
|
||||
"contain",
|
||||
"fill",
|
||||
"inside",
|
||||
"outside",
|
||||
] as const) {
|
||||
await expect(
|
||||
adapter.resize(MINIMAL_PNG, 100, 100, mode)
|
||||
).resolves.toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("throws CompressImageError for unknown format", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(adapter.resize(UNKNOWN, 100, 100)).rejects.toThrow(
|
||||
CompressImageError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── parseDimensions null fallback ─────────────────────────────────────────────
|
||||
|
||||
describe("MemoryAdapter parseDimensions null fallback", () => {
|
||||
const adapter = new MemoryAdapter();
|
||||
|
||||
it("falls back to 1x1 when PNG magic is valid but IHDR is truncated", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Valid PNG magic but buffer is too short to contain full IHDR
|
||||
const truncatedPng = new Uint8Array([
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0d, // IHDR length
|
||||
0x49,
|
||||
0x48,
|
||||
0x44,
|
||||
0x52, // "IHDR"
|
||||
// Width/height bytes missing — too short
|
||||
]);
|
||||
// The buffer has PNG magic so detectFormat returns 'png',
|
||||
// but parsePngDimensions returns null, triggering the ?? fallback
|
||||
const result = await adapter.compress(truncatedPng, {});
|
||||
expect(result.width).toBe(1);
|
||||
expect(result.height).toBe(1);
|
||||
});
|
||||
|
||||
it("falls back to 1x1 when JPEG SOI is valid but no SOF found", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Valid JPEG SOI + FF byte (passes detectFormat) but no SOF marker present
|
||||
// detectFormat needs data[2] === 0xff for JPEG; APP0 segment with no SOF after it
|
||||
const jpegNoSof = new Uint8Array([
|
||||
0xff,
|
||||
0xd8, // SOI
|
||||
0xff,
|
||||
0xe0, // APP0 marker
|
||||
0x00,
|
||||
0x04, // length = 4 (includes length bytes, 2 data bytes follow)
|
||||
0x00,
|
||||
0x00, // APP0 data (no SOF anywhere)
|
||||
]);
|
||||
// getInfo detects 'jpeg' but parseJpegDimensions returns null (no SOF marker)
|
||||
const info = await adapter.getInfo(jpegNoSof);
|
||||
expect(info.width).toBe(1);
|
||||
expect(info.height).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
COMPRESS_IMAGE_CODES,
|
||||
CompressImageError,
|
||||
} from "../../errors/compress-image-error.js";
|
||||
import type {
|
||||
CompressOptions,
|
||||
CompressResult,
|
||||
ImageAdapter,
|
||||
ImageFormat,
|
||||
ImageInfo,
|
||||
ResizeMode,
|
||||
} from "../../types/compress-image.types.js";
|
||||
import {
|
||||
detectFormat,
|
||||
parseJpegDimensions,
|
||||
parsePngDimensions,
|
||||
} from "../../utils/image-utils.js";
|
||||
|
||||
/** Default values applied when an option is not supplied by the caller. */
|
||||
const DEFAULTS = {
|
||||
quality: 80,
|
||||
resizeMode: "cover" as ResizeMode,
|
||||
stripMetadata: true,
|
||||
progressive: false,
|
||||
lossless: false,
|
||||
effort: 4,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Derive pixel dimensions from raw image bytes using format-specific parsers.
|
||||
*
|
||||
* Falls back to `{ width: 1, height: 1 }` for formats (WebP, AVIF) that
|
||||
* require a full decoder — the `MemoryAdapter` is intentionally test-only.
|
||||
*/
|
||||
function parseDimensions(
|
||||
data: Uint8Array,
|
||||
format: ImageFormat
|
||||
): { width: number; height: number } {
|
||||
if (format === "png") {
|
||||
return parsePngDimensions(data) ?? { width: 1, height: 1 };
|
||||
}
|
||||
if (format === "jpeg") {
|
||||
return parseJpegDimensions(data) ?? { width: 1, height: 1 };
|
||||
}
|
||||
// WebP and AVIF require a proper codec; return synthetic dimensions.
|
||||
return { width: 1, height: 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure-TypeScript image adapter suitable for unit testing and CI environments
|
||||
* where native Sharp or WebAssembly codecs are unavailable.
|
||||
*
|
||||
* **Design contract**: The `MemoryAdapter` does NOT perform real image decoding
|
||||
* or pixel manipulation. It parses PNG/JPEG magic bytes to extract dimensions
|
||||
* and synthesises `CompressResult` metadata (including a simulated
|
||||
* `compressionRatio` derived from the requested quality). The output `data`
|
||||
* field is always identical to the input — byte content is unchanged.
|
||||
*
|
||||
* This makes the adapter ideal for:
|
||||
* - Verifying the `ImageCompressor` option-validation and OTel instrumentation
|
||||
* paths without a heavy native dependency.
|
||||
* - Property-based tests that care about metadata contracts but not pixel fidelity.
|
||||
* - Seeding `createCompressor()` in shared test fixtures.
|
||||
*
|
||||
* For production workloads, use `SharpAdapter` instead.
|
||||
*/
|
||||
export class MemoryAdapter implements ImageAdapter {
|
||||
/**
|
||||
* "Compress" an image by returning the original bytes with synthetic
|
||||
* metadata computed from the compression options.
|
||||
*
|
||||
* The `compressionRatio` is simulated as `100 / quality` so that higher
|
||||
* quality values produce ratios closer to 1.0, matching the intuition that
|
||||
* low quality = aggressive compression.
|
||||
*
|
||||
* @param input - Raw image bytes.
|
||||
* @param options - Compression parameters.
|
||||
* @returns Synthetic `CompressResult` with unmodified `data`.
|
||||
* @throws {CompressImageError} When the input format cannot be detected.
|
||||
*/
|
||||
async compress(
|
||||
input: Uint8Array,
|
||||
options: CompressOptions
|
||||
): Promise<CompressResult> {
|
||||
const detectedFormat = detectFormat(input);
|
||||
if (detectedFormat === null) {
|
||||
throw new CompressImageError(
|
||||
COMPRESS_IMAGE_CODES["UNSUPPORTED_FORMAT"],
|
||||
"compress-image.memory_adapter.unsupported_format"
|
||||
);
|
||||
}
|
||||
|
||||
const format = options.format ?? detectedFormat;
|
||||
const quality = options.quality ?? DEFAULTS.quality;
|
||||
const dims = parseDimensions(input, detectedFormat);
|
||||
|
||||
const outWidth = options.width ?? dims.width;
|
||||
const outHeight = options.height ?? dims.height;
|
||||
|
||||
// Simulate compression: ratio is inversely proportional to quality.
|
||||
// quality 100 → ratio ≈ 1.0 (no compression)
|
||||
// quality 80 → ratio = 1.25
|
||||
// quality 50 → ratio = 2.0
|
||||
const compressionRatio = 100 / quality;
|
||||
const compressedSize = Math.max(
|
||||
1,
|
||||
Math.round(input.length / compressionRatio)
|
||||
);
|
||||
|
||||
return {
|
||||
data: input,
|
||||
format,
|
||||
width: outWidth,
|
||||
height: outHeight,
|
||||
originalSize: input.length,
|
||||
compressedSize,
|
||||
compressionRatio,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract image metadata by parsing magic bytes and dimension headers.
|
||||
*
|
||||
* Alpha channel detection is synthetic: PNG images are assumed to have
|
||||
* alpha (conservative default); JPEG images never have alpha.
|
||||
*
|
||||
* @param input - Raw image bytes.
|
||||
* @returns Image metadata.
|
||||
* @throws {CompressImageError} When the format cannot be detected.
|
||||
*/
|
||||
async getInfo(input: Uint8Array): Promise<ImageInfo> {
|
||||
const format = detectFormat(input);
|
||||
if (format === null) {
|
||||
throw new CompressImageError(
|
||||
COMPRESS_IMAGE_CODES["UNSUPPORTED_FORMAT"],
|
||||
"compress-image.memory_adapter.unsupported_format"
|
||||
);
|
||||
}
|
||||
|
||||
const dims = parseDimensions(input, format);
|
||||
|
||||
// Conservative alpha heuristic: PNG may have alpha, JPEG never does.
|
||||
const hasAlpha = format === "png" || format === "webp";
|
||||
|
||||
return {
|
||||
width: dims.width,
|
||||
height: dims.height,
|
||||
format,
|
||||
size: input.length,
|
||||
hasAlpha,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the input bytes unchanged (no real resize is performed).
|
||||
*
|
||||
* The target `width`, `height`, and `mode` parameters are accepted for
|
||||
* interface compatibility but have no effect on the returned bytes. Tests
|
||||
* that need real resize output should use `SharpAdapter`.
|
||||
*
|
||||
* @param input - Raw image bytes.
|
||||
* @param _width - Target width (ignored).
|
||||
* @param _height - Target height (ignored).
|
||||
* @param _mode - Resize mode (ignored).
|
||||
* @returns The original bytes, unmodified.
|
||||
* @throws {CompressImageError} When the format cannot be detected.
|
||||
*/
|
||||
async resize(
|
||||
input: Uint8Array,
|
||||
_width: number,
|
||||
_height: number,
|
||||
_mode?: ResizeMode
|
||||
): Promise<Uint8Array> {
|
||||
const format = detectFormat(input);
|
||||
if (format === null) {
|
||||
throw new CompressImageError(
|
||||
COMPRESS_IMAGE_CODES["UNSUPPORTED_FORMAT"],
|
||||
"compress-image.memory_adapter.unsupported_format"
|
||||
);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SharpAdapter, type SharpInstance, type SharpLike } from "./sharp";
|
||||
102
packages/media/image/compress-image/src/adapters/sharp/sharp.md
Normal file
102
packages/media/image/compress-image/src/adapters/sharp/sharp.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# sharp-adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides a production-grade `ImageAdapter` implementation backed by the Sharp image processing library. Sharp delivers native-speed JPEG, PNG, WebP, and AVIF encoding through libvips, making it the recommended adapter for server-side workloads where pixel-accurate compression and real resizing are required.
|
||||
|
||||
Sharp is deliberately **not** a direct dependency of this package. The caller supplies a Sharp factory function at construction time, which eliminates the native-addon from the `@suaveplan/compress-image` dependency tree and allows the package to be used in environments where Sharp cannot be installed (e.g., edge runtimes, bundled frontends, or CI test runs).
|
||||
|
||||
## Features
|
||||
|
||||
- Full `ImageAdapter` implementation using the real Sharp pipeline
|
||||
- JPEG progressive encoding, PNG compression levels, WebP lossless, AVIF effort tuning
|
||||
- Metadata stripping by default; opt-in preservation via `stripMetadata: false`
|
||||
- `ResizeMode` mapped to Sharp's `fit` option (cover / contain / fill / inside / outside)
|
||||
- Caller-supplied Sharp factory for dependency injection
|
||||
- Wraps all Sharp errors in `CompressImageError` with cause-chain forwarding
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import sharp from 'sharp';
|
||||
import { SharpAdapter } from '@suaveplan/compress-image/adapters/sharp';
|
||||
import { createCompressor } from '@suaveplan/compress-image/compressor';
|
||||
|
||||
const compressor = createCompressor(new SharpAdapter(sharp));
|
||||
|
||||
const result = await compressor.compress(imageBuffer, {
|
||||
format: 'webp',
|
||||
quality: 75,
|
||||
width: 800,
|
||||
height: 600,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import sharp from 'sharp';
|
||||
import { SharpAdapter } from '@suaveplan/compress-image/adapters/sharp';
|
||||
|
||||
const adapter = new SharpAdapter(sharp);
|
||||
|
||||
// Get metadata
|
||||
const info = await adapter.getInfo(jpegBuffer);
|
||||
console.log(info.width, info.height, info.format, info.hasAlpha);
|
||||
|
||||
// Convert JPEG to AVIF with high compression effort
|
||||
const result = await adapter.compress(jpegBuffer, {
|
||||
format: 'avif',
|
||||
quality: 60,
|
||||
effort: 6,
|
||||
stripMetadata: true,
|
||||
});
|
||||
|
||||
// Resize to thumbnail keeping aspect ratio (contain mode)
|
||||
const thumb = await adapter.resize(jpegBuffer, 128, 128, 'contain');
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface SharpLike`
|
||||
|
||||
A minimal factory interface matching the Sharp module export signature. Implement this to inject a mock in tests.
|
||||
|
||||
```ts
|
||||
interface SharpLike {
|
||||
(input: Buffer | Uint8Array): SharpInstance;
|
||||
}
|
||||
```
|
||||
|
||||
### `interface SharpInstance`
|
||||
|
||||
The fluent pipeline instance returned by `SharpLike`. Covers the methods used by `SharpAdapter`.
|
||||
|
||||
### `class SharpAdapter implements ImageAdapter`
|
||||
|
||||
#### `constructor(sharp: SharpLike)`
|
||||
|
||||
Stores the provided Sharp factory. No I/O occurs at construction time.
|
||||
|
||||
#### `compress(input: Uint8Array, options: CompressOptions): Promise<CompressResult>`
|
||||
|
||||
Runs the Sharp pipeline with format conversion, quality settings, and optional resize. Returns a `CompressResult` with real output bytes and actual dimensions. Throws `CompressImageError(UNSUPPORTED_FORMAT)` for undetectable inputs; throws `CompressImageError(COMPRESS_FAILED)` for all other Sharp errors.
|
||||
|
||||
#### `getInfo(input: Uint8Array): Promise<ImageInfo>`
|
||||
|
||||
Calls `sharp(input).metadata()` to extract width, height, format, and alpha channel information. Throws `CompressImageError(UNSUPPORTED_FORMAT)` if Sharp returns a format not in the known set.
|
||||
|
||||
#### `resize(input: Uint8Array, width: number, height: number, mode?: ResizeMode): Promise<Uint8Array>`
|
||||
|
||||
Resizes using `sharp.resize()` with the mapped `fit` option. Returns a `Uint8Array` wrapping the Sharp output `Buffer`. Default mode is `'cover'`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Format encoding options are mapped per-format: JPEG receives `quality` and `progressive`; WebP receives `quality`, `lossless`, and `effort`; AVIF receives `quality` and `effort`; PNG receives `compressionLevel` (mapped from `effort`) but no quality parameter since PNG is lossless.
|
||||
|
||||
A second `.metadata()` pass on the output buffer is used to derive the final `width` and `height` in `CompressResult`. This adds a small overhead but guarantees accuracy after resize operations where the actual output dimensions may differ from the requested values (e.g., when using `inside` or `outside` fit modes).
|
||||
|
||||
Sharp's `Buffer` is wrapped in `new Uint8Array(buffer)` to satisfy the `Uint8Array` contract in `CompressResult.data`, ensuring interoperability with environments that do not use Node.js `Buffer`.
|
||||
@@ -0,0 +1,537 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
COMPRESS_IMAGE_CODES,
|
||||
CompressImageError,
|
||||
} from "../../errors/compress-image-error.js";
|
||||
import type { SharpInstance, SharpLike } from "./sharp.js";
|
||||
import { SharpAdapter } from "./sharp.js";
|
||||
|
||||
// ── Minimal test fixtures ─────────────────────────────────────────────────────
|
||||
|
||||
const MINIMAL_PNG = new Uint8Array([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
|
||||
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde,
|
||||
]);
|
||||
|
||||
// ── Mock Sharp factory ────────────────────────────────────────────────────────
|
||||
|
||||
/** Creates a Sharp-like factory that throws a CompressImageError (for re-throw coverage). */
|
||||
function makeThrowingCompressImageErrorSharp(
|
||||
throwOn: "toBuffer" | "metadata"
|
||||
): SharpLike {
|
||||
const makeInstance = (): SharpInstance => ({
|
||||
resize(): SharpInstance {
|
||||
return makeInstance();
|
||||
},
|
||||
toFormat(): SharpInstance {
|
||||
return makeInstance();
|
||||
},
|
||||
withMetadata(): SharpInstance {
|
||||
return makeInstance();
|
||||
},
|
||||
async toBuffer(): Promise<Uint8Array> {
|
||||
if (throwOn === "toBuffer") {
|
||||
throw new CompressImageError(
|
||||
COMPRESS_IMAGE_CODES["COMPRESS_FAILED"],
|
||||
"compress-image.test.already_wrapped"
|
||||
);
|
||||
}
|
||||
return new Uint8Array(10);
|
||||
},
|
||||
async metadata() {
|
||||
if (throwOn === "metadata") {
|
||||
throw new CompressImageError(
|
||||
COMPRESS_IMAGE_CODES["COMPRESS_FAILED"],
|
||||
"compress-image.test.already_wrapped"
|
||||
);
|
||||
}
|
||||
return { width: 10, height: 10, format: "png", hasAlpha: false };
|
||||
},
|
||||
});
|
||||
return (_input: Uint8Array): SharpInstance => makeInstance();
|
||||
}
|
||||
|
||||
/** Creates a Sharp-like factory that throws a non-Error value. */
|
||||
function makeThrowingNonErrorSharp(
|
||||
throwOn: "toBuffer" | "metadata"
|
||||
): SharpLike {
|
||||
const makeInstance = (): SharpInstance => ({
|
||||
resize(): SharpInstance {
|
||||
return makeInstance();
|
||||
},
|
||||
toFormat(): SharpInstance {
|
||||
return makeInstance();
|
||||
},
|
||||
withMetadata(): SharpInstance {
|
||||
return makeInstance();
|
||||
},
|
||||
async toBuffer(): Promise<Uint8Array> {
|
||||
if (throwOn === "toBuffer") throw "string error";
|
||||
return new Uint8Array(10);
|
||||
},
|
||||
async metadata() {
|
||||
if (throwOn === "metadata") throw "string error";
|
||||
return { width: 10, height: 10, format: "png", hasAlpha: false };
|
||||
},
|
||||
});
|
||||
return (_input: Uint8Array): SharpInstance => makeInstance();
|
||||
}
|
||||
|
||||
/** Creates a mock Sharp factory that returns configurable results. */
|
||||
function makeMockSharp(overrides?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
format?: string;
|
||||
hasAlpha?: boolean;
|
||||
outputSize?: number;
|
||||
throwOn?: "toBuffer" | "metadata" | "both";
|
||||
returnUndefinedDimensions?: boolean;
|
||||
returnUndefinedFormat?: boolean;
|
||||
returnUndefinedHasAlpha?: boolean;
|
||||
}): SharpLike {
|
||||
const {
|
||||
width = 100,
|
||||
height = 80,
|
||||
format = "png",
|
||||
hasAlpha = false,
|
||||
outputSize = 500,
|
||||
throwOn,
|
||||
returnUndefinedDimensions = false,
|
||||
returnUndefinedFormat = false,
|
||||
returnUndefinedHasAlpha = false,
|
||||
} = overrides ?? {};
|
||||
|
||||
const outputData = new Uint8Array(outputSize).fill(0x42);
|
||||
|
||||
const makeInstance = (): SharpInstance => ({
|
||||
resize(_w?: number, _h?: number, _opts?: object): SharpInstance {
|
||||
return makeInstance();
|
||||
},
|
||||
toFormat(_fmt: string, _opts?: object): SharpInstance {
|
||||
return makeInstance();
|
||||
},
|
||||
withMetadata(): SharpInstance {
|
||||
return makeInstance();
|
||||
},
|
||||
async toBuffer(): Promise<Uint8Array> {
|
||||
if (throwOn === "toBuffer" || throwOn === "both") {
|
||||
throw new Error("sharp toBuffer error");
|
||||
}
|
||||
return outputData;
|
||||
},
|
||||
async metadata() {
|
||||
if (throwOn === "metadata" || throwOn === "both") {
|
||||
throw new Error("sharp metadata error");
|
||||
}
|
||||
if (returnUndefinedDimensions) {
|
||||
return {
|
||||
format: returnUndefinedFormat ? undefined : format,
|
||||
hasAlpha: returnUndefinedHasAlpha ? undefined : hasAlpha,
|
||||
};
|
||||
}
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
format: returnUndefinedFormat ? undefined : format,
|
||||
hasAlpha: returnUndefinedHasAlpha ? undefined : hasAlpha,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return (_input: Uint8Array): SharpInstance => makeInstance();
|
||||
}
|
||||
|
||||
// ── compress ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SharpAdapter.compress", () => {
|
||||
it("returns a CompressResult with correct format", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SharpAdapter(
|
||||
makeMockSharp({ width: 100, height: 80, format: "png" })
|
||||
);
|
||||
const result = await adapter.compress(MINIMAL_PNG, { format: "png" });
|
||||
expect(result.format).toBe("png");
|
||||
});
|
||||
|
||||
it("uses specified output format", { timeout: 5000 }, async () => {
|
||||
const adapter = new SharpAdapter(
|
||||
makeMockSharp({ width: 100, height: 80, format: "webp" })
|
||||
);
|
||||
const result = await adapter.compress(MINIMAL_PNG, {
|
||||
format: "webp",
|
||||
quality: 75,
|
||||
});
|
||||
expect(result.format).toBe("webp");
|
||||
});
|
||||
|
||||
it("sets width and height from metadata", { timeout: 5000 }, async () => {
|
||||
const adapter = new SharpAdapter(
|
||||
makeMockSharp({ width: 200, height: 150 })
|
||||
);
|
||||
const result = await adapter.compress(MINIMAL_PNG, {});
|
||||
expect(result.width).toBe(200);
|
||||
expect(result.height).toBe(150);
|
||||
});
|
||||
|
||||
it("returns Uint8Array in data field", { timeout: 5000 }, async () => {
|
||||
const adapter = new SharpAdapter(makeMockSharp());
|
||||
const result = await adapter.compress(MINIMAL_PNG, {});
|
||||
expect(result.data).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it("populates originalSize from input.length", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SharpAdapter(makeMockSharp({ outputSize: 300 }));
|
||||
const result = await adapter.compress(MINIMAL_PNG, {});
|
||||
expect(result.originalSize).toBe(MINIMAL_PNG.length);
|
||||
});
|
||||
|
||||
it("populates compressedSize from output buffer length", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SharpAdapter(makeMockSharp({ outputSize: 300 }));
|
||||
const result = await adapter.compress(MINIMAL_PNG, {});
|
||||
expect(result.compressedSize).toBe(300);
|
||||
});
|
||||
|
||||
it("computes compressionRatio as originalSize/compressedSize", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SharpAdapter(
|
||||
makeMockSharp({ outputSize: MINIMAL_PNG.length })
|
||||
);
|
||||
const result = await adapter.compress(MINIMAL_PNG, {});
|
||||
expect(result.compressionRatio).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it("applies resize options when width/height are specified", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SharpAdapter(
|
||||
makeMockSharp({ width: 640, height: 480 })
|
||||
);
|
||||
const result = await adapter.compress(MINIMAL_PNG, {
|
||||
width: 640,
|
||||
height: 480,
|
||||
resizeMode: "cover",
|
||||
});
|
||||
expect(result.width).toBe(640);
|
||||
expect(result.height).toBe(480);
|
||||
});
|
||||
|
||||
it("calls withMetadata when stripMetadata is false", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SharpAdapter(makeMockSharp());
|
||||
// Should not throw
|
||||
await expect(
|
||||
adapter.compress(MINIMAL_PNG, { stripMetadata: false })
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("handles JPEG format options", { timeout: 5000 }, async () => {
|
||||
const adapter = new SharpAdapter(makeMockSharp({ format: "jpeg" }));
|
||||
await expect(
|
||||
adapter.compress(MINIMAL_PNG, {
|
||||
format: "jpeg",
|
||||
quality: 85,
|
||||
progressive: true,
|
||||
})
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("handles WebP lossless option", { timeout: 5000 }, async () => {
|
||||
const adapter = new SharpAdapter(makeMockSharp({ format: "webp" }));
|
||||
await expect(
|
||||
adapter.compress(MINIMAL_PNG, {
|
||||
format: "webp",
|
||||
lossless: true,
|
||||
effort: 3,
|
||||
})
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("handles AVIF effort option", { timeout: 5000 }, async () => {
|
||||
const adapter = new SharpAdapter(makeMockSharp({ format: "avif" }));
|
||||
await expect(
|
||||
adapter.compress(MINIMAL_PNG, {
|
||||
format: "avif",
|
||||
quality: 60,
|
||||
effort: 5,
|
||||
})
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("uses default effort=4 for avif when effort is not specified", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SharpAdapter(makeMockSharp({ format: "avif" }));
|
||||
// No effort option → hits options.effort ?? 4 fallback (line 131)
|
||||
await expect(
|
||||
adapter.compress(MINIMAL_PNG, { format: "avif", quality: 60 })
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("uses default resizeMode=cover when width set but resizeMode absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SharpAdapter(
|
||||
makeMockSharp({ width: 200, height: 150 })
|
||||
);
|
||||
// width set, no explicit resizeMode → hits options.resizeMode ?? "cover" (line 115)
|
||||
const result = await adapter.compress(MINIMAL_PNG, { width: 200 });
|
||||
expect(result.width).toBe(200);
|
||||
});
|
||||
|
||||
it("returns compressionRatio=1 when output buffer is empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// outputSize: 0 → compressedSize === 0 → hits the false branch of
|
||||
// `compressedSize > 0 ? originalSize / compressedSize : 1` (line 159)
|
||||
const adapter = new SharpAdapter(makeMockSharp({ outputSize: 0 }));
|
||||
const result = await adapter.compress(MINIMAL_PNG, {});
|
||||
expect(result.compressionRatio).toBe(1);
|
||||
});
|
||||
|
||||
it("handles PNG compression level option", { timeout: 5000 }, async () => {
|
||||
const adapter = new SharpAdapter(makeMockSharp({ format: "png" }));
|
||||
await expect(
|
||||
adapter.compress(MINIMAL_PNG, { format: "png", effort: 6 })
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("throws CompressImageError for unknown input format", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SharpAdapter(makeMockSharp());
|
||||
await expect(
|
||||
adapter.compress(new Uint8Array([0x00, 0x01, 0x02, 0x03]), {})
|
||||
).rejects.toThrow(CompressImageError);
|
||||
});
|
||||
|
||||
it("wraps Sharp toBuffer error in CompressImageError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SharpAdapter(
|
||||
makeMockSharp({ throwOn: "toBuffer" })
|
||||
);
|
||||
await expect(adapter.compress(MINIMAL_PNG, {})).rejects.toThrow(
|
||||
CompressImageError
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps Sharp metadata error in CompressImageError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SharpAdapter(
|
||||
makeMockSharp({ throwOn: "metadata" })
|
||||
);
|
||||
await expect(adapter.compress(MINIMAL_PNG, {})).rejects.toThrow(
|
||||
CompressImageError
|
||||
);
|
||||
});
|
||||
|
||||
it("handles undefined width/height from metadata (fallback to 1)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SharpAdapter(
|
||||
makeMockSharp({ returnUndefinedDimensions: true })
|
||||
);
|
||||
const result = await adapter.compress(MINIMAL_PNG, {});
|
||||
expect(result.width).toBe(1);
|
||||
expect(result.height).toBe(1);
|
||||
});
|
||||
|
||||
it("wraps non-Error exception from compress in CompressImageError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SharpAdapter(makeThrowingNonErrorSharp("toBuffer"));
|
||||
await expect(adapter.compress(MINIMAL_PNG, {})).rejects.toThrow(
|
||||
CompressImageError
|
||||
);
|
||||
});
|
||||
|
||||
it("re-throws CompressImageError thrown inside compress try block", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// makeThrowingCompressImageErrorSharp throws CompressImageError from toBuffer,
|
||||
// hitting the `if (err instanceof CompressImageError) throw err` re-throw path.
|
||||
const adapter = new SharpAdapter(
|
||||
makeThrowingCompressImageErrorSharp("toBuffer")
|
||||
);
|
||||
await expect(adapter.compress(MINIMAL_PNG, {})).rejects.toThrow(
|
||||
CompressImageError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getInfo ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SharpAdapter.getInfo", () => {
|
||||
it("returns correct metadata", { timeout: 5000 }, async () => {
|
||||
const adapter = new SharpAdapter(
|
||||
makeMockSharp({
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
format: "jpeg",
|
||||
hasAlpha: false,
|
||||
})
|
||||
);
|
||||
const info = await adapter.getInfo(MINIMAL_PNG);
|
||||
expect(info.width).toBe(1920);
|
||||
expect(info.height).toBe(1080);
|
||||
expect(info.format).toBe("jpeg");
|
||||
expect(info.hasAlpha).toBe(false);
|
||||
expect(info.size).toBe(MINIMAL_PNG.length);
|
||||
});
|
||||
|
||||
it("maps 'jpg' format to 'jpeg'", { timeout: 5000 }, async () => {
|
||||
const adapter = new SharpAdapter(makeMockSharp({ format: "jpg" }));
|
||||
const info = await adapter.getInfo(MINIMAL_PNG);
|
||||
expect(info.format).toBe("jpeg");
|
||||
});
|
||||
|
||||
it("reports hasAlpha true when Sharp says so", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SharpAdapter(makeMockSharp({ hasAlpha: true }));
|
||||
const info = await adapter.getInfo(MINIMAL_PNG);
|
||||
expect(info.hasAlpha).toBe(true);
|
||||
});
|
||||
|
||||
it("throws CompressImageError for unknown format from Sharp", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SharpAdapter(makeMockSharp({ format: "tiff" }));
|
||||
await expect(adapter.getInfo(MINIMAL_PNG)).rejects.toThrow(
|
||||
CompressImageError
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps Sharp metadata error in CompressImageError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SharpAdapter(
|
||||
makeMockSharp({ throwOn: "metadata" })
|
||||
);
|
||||
await expect(adapter.getInfo(MINIMAL_PNG)).rejects.toThrow(
|
||||
CompressImageError
|
||||
);
|
||||
});
|
||||
|
||||
it("handles undefined width/height (fallback to 1)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SharpAdapter(
|
||||
makeMockSharp({ returnUndefinedDimensions: true, format: "png" })
|
||||
);
|
||||
const info = await adapter.getInfo(MINIMAL_PNG);
|
||||
expect(info.width).toBe(1);
|
||||
expect(info.height).toBe(1);
|
||||
});
|
||||
|
||||
it("wraps non-Error exception from metadata in CompressImageError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SharpAdapter(makeThrowingNonErrorSharp("metadata"));
|
||||
await expect(adapter.getInfo(MINIMAL_PNG)).rejects.toThrow(
|
||||
CompressImageError
|
||||
);
|
||||
});
|
||||
|
||||
it("re-throws CompressImageError thrown inside getInfo try block", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// makeThrowingCompressImageErrorSharp throws a CompressImageError from metadata,
|
||||
// hitting the `if (err instanceof CompressImageError) throw err` re-throw path.
|
||||
const adapter = new SharpAdapter(
|
||||
makeThrowingCompressImageErrorSharp("metadata")
|
||||
);
|
||||
await expect(adapter.getInfo(MINIMAL_PNG)).rejects.toThrow(
|
||||
CompressImageError
|
||||
);
|
||||
});
|
||||
|
||||
it("uses empty string fallback when Sharp returns undefined format", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Undefined format → `meta.format ?? ""` → REVERSE_FORMAT_MAP[""] = undefined → throws
|
||||
const adapter = new SharpAdapter(
|
||||
makeMockSharp({ returnUndefinedFormat: true })
|
||||
);
|
||||
await expect(adapter.getInfo(MINIMAL_PNG)).rejects.toThrow(
|
||||
CompressImageError
|
||||
);
|
||||
});
|
||||
|
||||
it("uses false fallback when Sharp returns undefined hasAlpha", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SharpAdapter(
|
||||
makeMockSharp({ returnUndefinedHasAlpha: true, format: "png" })
|
||||
);
|
||||
const info = await adapter.getInfo(MINIMAL_PNG);
|
||||
expect(info.hasAlpha).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── resize ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SharpAdapter.resize", () => {
|
||||
it("returns a Uint8Array", { timeout: 5000 }, async () => {
|
||||
const adapter = new SharpAdapter(makeMockSharp({ outputSize: 200 }));
|
||||
const result = await adapter.resize(MINIMAL_PNG, 100, 100);
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
expect(result.length).toBe(200);
|
||||
});
|
||||
|
||||
it("accepts resize mode", { timeout: 5000 }, async () => {
|
||||
const adapter = new SharpAdapter(makeMockSharp());
|
||||
await expect(
|
||||
adapter.resize(MINIMAL_PNG, 64, 64, "contain")
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("uses cover as default resize mode", { timeout: 5000 }, async () => {
|
||||
const adapter = new SharpAdapter(makeMockSharp());
|
||||
await expect(
|
||||
adapter.resize(MINIMAL_PNG, 64, 64)
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("wraps Sharp toBuffer error in CompressImageError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SharpAdapter(
|
||||
makeMockSharp({ throwOn: "toBuffer" })
|
||||
);
|
||||
await expect(adapter.resize(MINIMAL_PNG, 50, 50)).rejects.toThrow(
|
||||
CompressImageError
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps non-Error exception from resize in CompressImageError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new SharpAdapter(makeThrowingNonErrorSharp("toBuffer"));
|
||||
await expect(adapter.resize(MINIMAL_PNG, 50, 50)).rejects.toThrow(
|
||||
CompressImageError
|
||||
);
|
||||
});
|
||||
|
||||
it("re-throws CompressImageError thrown inside resize try block", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// makeThrowingCompressImageErrorSharp throws CompressImageError from toBuffer,
|
||||
// hitting the `if (err instanceof CompressImageError) throw err` re-throw path.
|
||||
const adapter = new SharpAdapter(
|
||||
makeThrowingCompressImageErrorSharp("toBuffer")
|
||||
);
|
||||
await expect(adapter.resize(MINIMAL_PNG, 50, 50)).rejects.toThrow(
|
||||
CompressImageError
|
||||
);
|
||||
});
|
||||
});
|
||||
248
packages/media/image/compress-image/src/adapters/sharp/sharp.ts
Normal file
248
packages/media/image/compress-image/src/adapters/sharp/sharp.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
COMPRESS_IMAGE_CODES,
|
||||
CompressImageError,
|
||||
} from "../../errors/compress-image-error.js";
|
||||
import type {
|
||||
CompressOptions,
|
||||
CompressResult,
|
||||
ImageAdapter,
|
||||
ImageFormat,
|
||||
ImageInfo,
|
||||
ResizeMode,
|
||||
} from "../../types/compress-image.types.js";
|
||||
import { detectFormat } from "../../utils/image-utils.js";
|
||||
|
||||
/**
|
||||
* Minimal subset of the Sharp API required by `SharpAdapter`.
|
||||
*
|
||||
* The full `sharp` package is not a direct dependency of this package.
|
||||
* The caller provides a Sharp factory function at construction time, making
|
||||
* the adapter usable in any environment where Sharp is installed without
|
||||
* coupling this package to Sharp's native addon.
|
||||
*/
|
||||
export type SharpLike = (input: Uint8Array) => SharpInstance;
|
||||
|
||||
/** Fluent instance returned by a `SharpLike` factory. */
|
||||
export interface SharpInstance {
|
||||
resize(w?: number, h?: number, options?: object): SharpInstance;
|
||||
toFormat(format: string, options?: object): SharpInstance;
|
||||
withMetadata(): SharpInstance;
|
||||
toBuffer(): Promise<Uint8Array>;
|
||||
metadata(): Promise<{
|
||||
width?: number;
|
||||
height?: number;
|
||||
format?: string;
|
||||
size?: number;
|
||||
hasAlpha?: boolean;
|
||||
channels?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Maps our `ImageFormat` to the corresponding Sharp format string. */
|
||||
const FORMAT_MAP: Record<ImageFormat, string> = {
|
||||
jpeg: "jpeg",
|
||||
png: "png",
|
||||
webp: "webp",
|
||||
avif: "avif",
|
||||
};
|
||||
|
||||
/** Maps Sharp's format strings back to `ImageFormat`. */
|
||||
const REVERSE_FORMAT_MAP: Record<string, ImageFormat> = {
|
||||
jpeg: "jpeg",
|
||||
jpg: "jpeg",
|
||||
png: "png",
|
||||
webp: "webp",
|
||||
avif: "avif",
|
||||
};
|
||||
|
||||
/** Maps `ResizeMode` to the Sharp `fit` option string. */
|
||||
const FIT_MAP: Record<ResizeMode, string> = {
|
||||
cover: "cover",
|
||||
contain: "contain",
|
||||
fill: "fill",
|
||||
inside: "inside",
|
||||
outside: "outside",
|
||||
};
|
||||
|
||||
/**
|
||||
* Production-grade `ImageAdapter` backed by the Sharp image processing library.
|
||||
*
|
||||
* Sharp is **not** a direct dependency of this package. The caller supplies a
|
||||
* Sharp factory function at construction time, which decouples this package from
|
||||
* the native addon and allows the same adapter code to work with mock Sharp
|
||||
* instances in environments where the native module is unavailable.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import sharp from 'sharp';
|
||||
* import { SharpAdapter } from '@suaveplan/compress-image/adapters/sharp';
|
||||
* import { createCompressor } from '@suaveplan/compress-image/compressor';
|
||||
*
|
||||
* const compressor = createCompressor(new SharpAdapter(sharp));
|
||||
* ```
|
||||
*/
|
||||
export class SharpAdapter implements ImageAdapter {
|
||||
constructor(protected readonly sharp: SharpLike) {}
|
||||
|
||||
/**
|
||||
* Compress an image using Sharp with the provided options.
|
||||
*
|
||||
* @param input - Raw image bytes.
|
||||
* @param options - Compression parameters.
|
||||
* @returns Compressed image result.
|
||||
* @throws {CompressImageError} On unsupported format or Sharp pipeline failure.
|
||||
*/
|
||||
async compress(
|
||||
input: Uint8Array,
|
||||
options: CompressOptions
|
||||
): Promise<CompressResult> {
|
||||
const detectedFormat = detectFormat(input);
|
||||
if (detectedFormat === null) {
|
||||
throw new CompressImageError(
|
||||
COMPRESS_IMAGE_CODES["UNSUPPORTED_FORMAT"],
|
||||
"compress-image.sharp_adapter.unsupported_format"
|
||||
);
|
||||
}
|
||||
|
||||
const targetFormat = options.format ?? detectedFormat;
|
||||
const quality = options.quality ?? 80;
|
||||
|
||||
try {
|
||||
let pipeline = this.sharp(input);
|
||||
|
||||
// Apply resize if dimensions are specified
|
||||
if (options.width !== undefined || options.height !== undefined) {
|
||||
const fitOption = FIT_MAP[options.resizeMode ?? "cover"];
|
||||
pipeline = pipeline.resize(options.width, options.height, {
|
||||
fit: fitOption,
|
||||
});
|
||||
}
|
||||
|
||||
// Format-specific encoding options
|
||||
const formatOptions: Record<string, unknown> = {
|
||||
quality,
|
||||
progressive: options.progressive ?? false,
|
||||
};
|
||||
if (targetFormat === "webp") {
|
||||
formatOptions["lossless"] = options.lossless ?? false;
|
||||
formatOptions["effort"] = options.effort ?? 4;
|
||||
delete formatOptions["progressive"];
|
||||
} else if (targetFormat === "avif") {
|
||||
formatOptions["effort"] = options.effort ?? 4;
|
||||
delete formatOptions["progressive"];
|
||||
} else if (targetFormat === "png") {
|
||||
formatOptions["compressionLevel"] = options.effort ?? 4;
|
||||
delete formatOptions["quality"];
|
||||
delete formatOptions["progressive"];
|
||||
}
|
||||
|
||||
pipeline = pipeline.toFormat(
|
||||
FORMAT_MAP[targetFormat],
|
||||
formatOptions
|
||||
);
|
||||
|
||||
// Preserve metadata if stripMetadata is explicitly false
|
||||
if (options.stripMetadata === false) {
|
||||
pipeline = pipeline.withMetadata();
|
||||
}
|
||||
|
||||
const outputBuffer = await pipeline.toBuffer();
|
||||
|
||||
// Get output dimensions via a second pass
|
||||
const meta = await this.sharp(outputBuffer).metadata();
|
||||
|
||||
const outWidth = meta.width ?? 1;
|
||||
const outHeight = meta.height ?? 1;
|
||||
const originalSize = input.length;
|
||||
const compressedSize = outputBuffer.length;
|
||||
const compressionRatio =
|
||||
compressedSize > 0 ? originalSize / compressedSize : 1;
|
||||
|
||||
return {
|
||||
data: new Uint8Array(outputBuffer),
|
||||
format: targetFormat,
|
||||
width: outWidth,
|
||||
height: outHeight,
|
||||
originalSize,
|
||||
compressedSize,
|
||||
compressionRatio,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof CompressImageError) throw err;
|
||||
throw new CompressImageError(
|
||||
COMPRESS_IMAGE_CODES["COMPRESS_FAILED"],
|
||||
`compress-image.sharp_adapter.compress_failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err instanceof Error ? err : undefined }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract image metadata using Sharp's `.metadata()` method.
|
||||
*
|
||||
* @param input - Raw image bytes.
|
||||
* @returns Image metadata.
|
||||
* @throws {CompressImageError} On Sharp pipeline failure.
|
||||
*/
|
||||
async getInfo(input: Uint8Array): Promise<ImageInfo> {
|
||||
try {
|
||||
const meta = await this.sharp(input).metadata();
|
||||
|
||||
const rawFormat = meta.format ?? "";
|
||||
const format: ImageFormat | undefined =
|
||||
REVERSE_FORMAT_MAP[rawFormat];
|
||||
if (format === undefined) {
|
||||
throw new CompressImageError(
|
||||
COMPRESS_IMAGE_CODES["UNSUPPORTED_FORMAT"],
|
||||
`compress-image.sharp_adapter.unrecognized_format: ${rawFormat}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
width: meta.width ?? 1,
|
||||
height: meta.height ?? 1,
|
||||
format,
|
||||
size: input.length,
|
||||
hasAlpha: meta.hasAlpha ?? false,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof CompressImageError) throw err;
|
||||
throw new CompressImageError(
|
||||
COMPRESS_IMAGE_CODES["COMPRESS_FAILED"],
|
||||
`compress-image.sharp_adapter.get_info_failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err instanceof Error ? err : undefined }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize an image to the specified dimensions using Sharp.
|
||||
*
|
||||
* @param input - Raw image bytes.
|
||||
* @param width - Target width in pixels.
|
||||
* @param height - Target height in pixels.
|
||||
* @param mode - Resize strategy. Default 'cover'.
|
||||
* @returns Resized image bytes.
|
||||
* @throws {CompressImageError} On Sharp pipeline failure.
|
||||
*/
|
||||
async resize(
|
||||
input: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
mode: ResizeMode = "cover"
|
||||
): Promise<Uint8Array> {
|
||||
try {
|
||||
const outputBuffer = await this.sharp(input)
|
||||
.resize(width, height, { fit: FIT_MAP[mode] })
|
||||
.toBuffer();
|
||||
return new Uint8Array(outputBuffer);
|
||||
} catch (err) {
|
||||
if (err instanceof CompressImageError) throw err;
|
||||
throw new CompressImageError(
|
||||
COMPRESS_IMAGE_CODES["COMPRESS_FAILED"],
|
||||
`compress-image.sharp_adapter.resize_failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err instanceof Error ? err : undefined }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
# compressor
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `ImageCompressor` class is the primary entry point for the `@suaveplan/compress-image` package. It orchestrates all image processing operations through a pluggable `ImageAdapter`, adds Zod-based option validation, dimension range checking, and OpenTelemetry instrumentation, and exposes a convenience `compressIfLarger` method for conditional compression pipelines.
|
||||
|
||||
Application code should interact with `ImageCompressor` (or the `createCompressor` factory) rather than calling adapters directly, to benefit from validation and observability.
|
||||
|
||||
## Features
|
||||
|
||||
- Pluggable adapter pattern: swap `MemoryAdapter` ↔ `SharpAdapter` without changing application code
|
||||
- Zod option validation with structured `CompressImageError(INVALID_OPTIONS)` on failure
|
||||
- Dimension range validation (1–32767 px) before delegating to adapter
|
||||
- OTel counter `compress_image.compress.total{format, outcome}` — incremented on every `compress()` call
|
||||
- OTel histogram `compress_image.compress.duration_ms{format, outcome}` — recorded per call
|
||||
- `compressIfLarger` for size-threshold-based conditional compression
|
||||
- `createCompressor` factory for functional-style initialization
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createCompressor } from '@suaveplan/compress-image/compressor';
|
||||
|
||||
// Default adapter: MemoryAdapter (testing)
|
||||
const compressor = createCompressor();
|
||||
const result = await compressor.compress(pngBuffer, { quality: 80 });
|
||||
console.log(result.compressionRatio, result.format);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import sharp from 'sharp';
|
||||
import { createCompressor } from '@suaveplan/compress-image/compressor';
|
||||
import { SharpAdapter } from '@suaveplan/compress-image/adapters/sharp';
|
||||
|
||||
const compressor = createCompressor(new SharpAdapter(sharp));
|
||||
|
||||
// Compress with resize and format conversion
|
||||
const result = await compressor.compress(rawBuffer, {
|
||||
format: 'webp',
|
||||
quality: 75,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
resizeMode: 'contain',
|
||||
stripMetadata: true,
|
||||
progressive: false,
|
||||
effort: 5,
|
||||
});
|
||||
|
||||
// Conditionally compress: only if > 200 KB
|
||||
const maybe = await compressor.compressIfLarger(rawBuffer, 200 * 1024, {
|
||||
format: 'jpeg',
|
||||
quality: 70,
|
||||
});
|
||||
|
||||
// Get info without compressing
|
||||
const info = await compressor.getInfo(rawBuffer);
|
||||
|
||||
// Resize only
|
||||
const thumb = await compressor.resize(rawBuffer, 128, 128, 'cover');
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class ImageCompressor`
|
||||
|
||||
#### `constructor(adapter?: ImageAdapter)`
|
||||
|
||||
Creates a new compressor. Defaults to `MemoryAdapter` when no adapter is provided.
|
||||
|
||||
#### `setAdapter(adapter: ImageAdapter): void`
|
||||
|
||||
Replaces the active adapter. Useful for switching between testing and production adapters at runtime.
|
||||
|
||||
#### `compress(input: Uint8Array, options?: CompressOptions): Promise<CompressResult>`
|
||||
|
||||
Validates options with Zod, checks dimension bounds, delegates to the adapter, and records OTel metrics. Returns a `CompressResult`. Throws `CompressImageError(INVALID_OPTIONS)` on bad options; throws `CompressImageError(UNSUPPORTED_FORMAT)` or `CompressImageError(COMPRESS_FAILED)` from the adapter.
|
||||
|
||||
#### `getInfo(input: Uint8Array): Promise<ImageInfo>`
|
||||
|
||||
Delegates directly to `adapter.getInfo()` — no option validation required.
|
||||
|
||||
#### `resize(input: Uint8Array, width: number, height: number, mode?: ResizeMode): Promise<Uint8Array>`
|
||||
|
||||
Validates `width` and `height` ranges, then delegates to `adapter.resize()`. Throws `CompressImageError(INVALID_OPTIONS)` for out-of-range dimensions.
|
||||
|
||||
#### `compressIfLarger(input: Uint8Array, maxBytes: number, options?: CompressOptions): Promise<CompressResult>`
|
||||
|
||||
Returns the original bytes wrapped in a synthetic `CompressResult` (`compressionRatio = 1`) when `input.length <= maxBytes`. Otherwise delegates to `compress()`. Always returns `CompressResult` so downstream code has a consistent return type.
|
||||
|
||||
### `createCompressor(adapter?: ImageAdapter): ImageCompressor`
|
||||
|
||||
Equivalent to `new ImageCompressor(adapter)`. Returns a configured `ImageCompressor`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
OTel instruments are lazily initialized on first use inside `getCounter()` and `getDuration()`. This avoids registering metrics at module load time, which would interfere with test environments that configure the OTel SDK after imports are resolved.
|
||||
|
||||
The `format` OTel attribute defaults to `'unknown'` when `options.format` is not specified, since the detected format is not available until the adapter resolves it. This is a deliberate trade-off: the counter fires unconditionally so monitoring captures 100% of calls.
|
||||
|
||||
`compressIfLarger` calls `adapter.getInfo()` for the bypass path to populate `format`, `width`, and `height` in the returned `CompressResult`. This costs one metadata parse but gives callers a usable result regardless of whether compression ran.
|
||||
@@ -0,0 +1,374 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MemoryAdapter } from "../../adapters/memory/memory.js";
|
||||
import { CompressImageError } from "../../errors/compress-image-error.js";
|
||||
import type {
|
||||
CompressOptions,
|
||||
ImageAdapter,
|
||||
} from "../../types/compress-image.types.js";
|
||||
import { createCompressor, ImageCompressor } from "./compressor.js";
|
||||
|
||||
// ── Test fixtures ─────────────────────────────────────────────────────────────
|
||||
|
||||
const MINIMAL_PNG = new Uint8Array([
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0d,
|
||||
0x49,
|
||||
0x48,
|
||||
0x44,
|
||||
0x52,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x08, // width = 8
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x08, // height = 8
|
||||
0x08,
|
||||
0x02,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x4b,
|
||||
0x6d,
|
||||
0x29,
|
||||
0x58,
|
||||
]);
|
||||
|
||||
const MINIMAL_JPEG = new Uint8Array([
|
||||
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01,
|
||||
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xc0, 0x00, 0x0b,
|
||||
0x08, 0x00, 0x10, 0x00, 0x10, 0x01,
|
||||
]);
|
||||
|
||||
// ── createCompressor ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("createCompressor", () => {
|
||||
it("creates an ImageCompressor with default MemoryAdapter", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const compressor = createCompressor();
|
||||
expect(compressor).toBeInstanceOf(ImageCompressor);
|
||||
});
|
||||
|
||||
it("creates an ImageCompressor with the provided adapter", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const adapter = new MemoryAdapter();
|
||||
const compressor = createCompressor(adapter);
|
||||
expect(compressor).toBeInstanceOf(ImageCompressor);
|
||||
});
|
||||
});
|
||||
|
||||
// ── setAdapter ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ImageCompressor.setAdapter", () => {
|
||||
it("replaces the adapter used for subsequent calls", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const compressor = createCompressor();
|
||||
const newAdapter = new MemoryAdapter();
|
||||
compressor.setAdapter(newAdapter);
|
||||
// Should still work with the new adapter
|
||||
const result = await compressor.compress(MINIMAL_PNG, {});
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── compress ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ImageCompressor.compress", () => {
|
||||
it("delegates to the adapter and returns a result", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
const result = await compressor.compress(MINIMAL_PNG, { quality: 75 });
|
||||
expect(result.format).toBe("png");
|
||||
expect(result.originalSize).toBe(MINIMAL_PNG.length);
|
||||
});
|
||||
|
||||
it("accepts an empty options object", { timeout: 5000 }, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
await expect(
|
||||
compressor.compress(MINIMAL_PNG, {})
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("accepts undefined options", { timeout: 5000 }, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
await expect(compressor.compress(MINIMAL_PNG)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("validates quality range: throws for quality 0", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
await expect(
|
||||
compressor.compress(MINIMAL_PNG, { quality: 0 })
|
||||
).rejects.toThrow(CompressImageError);
|
||||
});
|
||||
|
||||
it("validates quality range: throws for quality 101", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
await expect(
|
||||
compressor.compress(MINIMAL_PNG, { quality: 101 })
|
||||
).rejects.toThrow(CompressImageError);
|
||||
});
|
||||
|
||||
it("validates effort range: throws for effort 7", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
await expect(
|
||||
compressor.compress(MINIMAL_PNG, { effort: 7 })
|
||||
).rejects.toThrow(CompressImageError);
|
||||
});
|
||||
|
||||
it("validates dimensions: throws for width > 32767", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
await expect(
|
||||
compressor.compress(MINIMAL_PNG, { width: 40000 })
|
||||
).rejects.toThrow(CompressImageError);
|
||||
});
|
||||
|
||||
it("validates dimensions: throws for height 0", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
await expect(
|
||||
compressor.compress(MINIMAL_PNG, { height: 0 })
|
||||
).rejects.toThrow(CompressImageError);
|
||||
});
|
||||
|
||||
it("accepts valid format option", { timeout: 5000 }, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
const result = await compressor.compress(MINIMAL_PNG, {
|
||||
format: "webp",
|
||||
});
|
||||
expect(result.format).toBe("webp");
|
||||
});
|
||||
|
||||
it("accepts valid resizeMode option", { timeout: 5000 }, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
await expect(
|
||||
compressor.compress(MINIMAL_PNG, { resizeMode: "contain" })
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("accepts boolean options", { timeout: 5000 }, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
await expect(
|
||||
compressor.compress(MINIMAL_PNG, {
|
||||
progressive: true,
|
||||
lossless: true,
|
||||
stripMetadata: false,
|
||||
})
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("re-throws adapter errors as-is when they are CompressImageError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const failAdapter: ImageAdapter = {
|
||||
async compress() {
|
||||
throw new CompressImageError(
|
||||
"boom",
|
||||
"COMPRESS_IMAGE.COMPRESS_FAILED"
|
||||
);
|
||||
},
|
||||
async getInfo() {
|
||||
return {
|
||||
width: 1,
|
||||
height: 1,
|
||||
format: "png",
|
||||
size: 1,
|
||||
hasAlpha: false,
|
||||
};
|
||||
},
|
||||
async resize(input) {
|
||||
return input;
|
||||
},
|
||||
};
|
||||
const compressor = createCompressor(failAdapter);
|
||||
await expect(compressor.compress(MINIMAL_PNG, {})).rejects.toThrow(
|
||||
CompressImageError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getInfo ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ImageCompressor.getInfo", () => {
|
||||
it("returns info for a valid PNG", { timeout: 5000 }, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
const info = await compressor.getInfo(MINIMAL_PNG);
|
||||
expect(info.format).toBe("png");
|
||||
expect(info.size).toBe(MINIMAL_PNG.length);
|
||||
});
|
||||
|
||||
it("returns info for a valid JPEG", { timeout: 5000 }, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
const info = await compressor.getInfo(MINIMAL_JPEG);
|
||||
expect(info.format).toBe("jpeg");
|
||||
});
|
||||
|
||||
it("throws for unknown format", { timeout: 5000 }, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
await expect(
|
||||
compressor.getInfo(new Uint8Array([0x00, 0x01, 0x02, 0x03]))
|
||||
).rejects.toThrow(CompressImageError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── resize ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ImageCompressor.resize", () => {
|
||||
it("resizes via the adapter", { timeout: 5000 }, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
const result = await compressor.resize(MINIMAL_PNG, 100, 100);
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it("accepts a ResizeMode", { timeout: 5000 }, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
await expect(
|
||||
compressor.resize(MINIMAL_PNG, 50, 50, "inside")
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("throws for invalid width", { timeout: 5000 }, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
await expect(compressor.resize(MINIMAL_PNG, -1, 100)).rejects.toThrow(
|
||||
CompressImageError
|
||||
);
|
||||
});
|
||||
|
||||
it("throws for invalid height", { timeout: 5000 }, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
await expect(compressor.resize(MINIMAL_PNG, 100, 0)).rejects.toThrow(
|
||||
CompressImageError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── compressIfLarger ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("ImageCompressor.compressIfLarger", () => {
|
||||
it("returns original bytes when input is below threshold", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
const bigThreshold = MINIMAL_PNG.length + 1000;
|
||||
const result = await compressor.compressIfLarger(
|
||||
MINIMAL_PNG,
|
||||
bigThreshold
|
||||
);
|
||||
expect(result.data).toBe(MINIMAL_PNG);
|
||||
expect(result.compressionRatio).toBe(1);
|
||||
expect(result.originalSize).toBe(result.compressedSize);
|
||||
});
|
||||
|
||||
it("returns original bytes when input equals threshold", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
const result = await compressor.compressIfLarger(
|
||||
MINIMAL_PNG,
|
||||
MINIMAL_PNG.length
|
||||
);
|
||||
expect(result.compressionRatio).toBe(1);
|
||||
});
|
||||
|
||||
it("compresses when input exceeds threshold", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
const result = await compressor.compressIfLarger(MINIMAL_PNG, 1, {
|
||||
quality: 50,
|
||||
});
|
||||
// Should have actually called compress, so compressionRatio != 1
|
||||
expect(result.compressionRatio).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("preserves format metadata in bypass path", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
const result = await compressor.compressIfLarger(
|
||||
MINIMAL_PNG,
|
||||
MINIMAL_PNG.length + 100
|
||||
);
|
||||
expect(result.format).toBe("png");
|
||||
expect(result.width).toBeGreaterThan(0);
|
||||
expect(result.height).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── OTel instrumentation ──────────────────────────────────────────────────────
|
||||
|
||||
describe("ImageCompressor OTel", () => {
|
||||
it("does not throw during OTel metric recording (noop provider)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
// No OTel SDK configured — noop provider should silently discard
|
||||
await expect(
|
||||
compressor.compress(MINIMAL_PNG, { format: "jpeg" })
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── error paths for schema validation ────────────────────────────────────────
|
||||
|
||||
describe("ImageCompressor schema validation", () => {
|
||||
it("throws CompressImageError with INVALID_OPTIONS code for bad quality", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
try {
|
||||
await compressor.compress(MINIMAL_PNG, { quality: -5 });
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CompressImageError);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-integer quality", { timeout: 5000 }, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
await expect(
|
||||
compressor.compress(MINIMAL_PNG, { quality: 80.5 })
|
||||
).rejects.toThrow(CompressImageError);
|
||||
});
|
||||
|
||||
it("rejects invalid format string", { timeout: 5000 }, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
await expect(
|
||||
compressor.compress(MINIMAL_PNG, {
|
||||
format: "tiff" as CompressOptions["format"],
|
||||
})
|
||||
).rejects.toThrow(CompressImageError);
|
||||
});
|
||||
|
||||
it("rejects invalid resizeMode string", { timeout: 5000 }, async () => {
|
||||
const compressor = createCompressor(new MemoryAdapter());
|
||||
await expect(
|
||||
compressor.compress(MINIMAL_PNG, {
|
||||
resizeMode: "stretch" as CompressOptions["resizeMode"],
|
||||
})
|
||||
).rejects.toThrow(CompressImageError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { Counter, Histogram } from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { MemoryAdapter } from "../../adapters/memory/memory.js";
|
||||
import {
|
||||
COMPRESS_IMAGE_CODES,
|
||||
CompressImageError,
|
||||
} from "../../errors/compress-image-error.js";
|
||||
import { CompressOptionsSchema } from "../../schemas/compress-options.schema.js";
|
||||
import type {
|
||||
CompressOptions,
|
||||
CompressResult,
|
||||
ImageAdapter,
|
||||
ImageInfo,
|
||||
ResizeMode,
|
||||
} from "../../types/compress-image.types.js";
|
||||
import { validateDimensions } from "../../utils/image-utils.js";
|
||||
|
||||
/** OTel metric names. */
|
||||
const METRIC_COMPRESS_TOTAL = "compress_image.compress.total";
|
||||
const METRIC_COMPRESS_DURATION_MS = "compress_image.compress.duration_ms";
|
||||
|
||||
/**
|
||||
* Lazily-initialized OTel instruments.
|
||||
* Initialised on first use to avoid registering instruments at module load time.
|
||||
*/
|
||||
let compressCounter: Counter | undefined;
|
||||
let compressDuration: Histogram | undefined;
|
||||
|
||||
function getCounter(): Counter {
|
||||
if (!compressCounter) {
|
||||
const meter = metrics.getMeter("@suaveplan/compress-image");
|
||||
compressCounter = meter.createCounter(METRIC_COMPRESS_TOTAL, {
|
||||
description: "Total number of compress() invocations",
|
||||
});
|
||||
}
|
||||
return compressCounter;
|
||||
}
|
||||
|
||||
function getDuration(): Histogram {
|
||||
if (!compressDuration) {
|
||||
const meter = metrics.getMeter("@suaveplan/compress-image");
|
||||
compressDuration = meter.createHistogram(METRIC_COMPRESS_DURATION_MS, {
|
||||
description: "Duration of compress() in milliseconds",
|
||||
unit: "ms",
|
||||
});
|
||||
}
|
||||
return compressDuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize `CompressOptions` using the Zod schema.
|
||||
*
|
||||
* Throws `CompressImageError(INVALID_OPTIONS)` on validation failure so callers
|
||||
* receive a structured error with the Zod issue messages embedded.
|
||||
*/
|
||||
function parseOptions(raw: CompressOptions): CompressOptions {
|
||||
const result = CompressOptionsSchema.safeParse(raw);
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||
.join("; ");
|
||||
throw new CompressImageError(
|
||||
COMPRESS_IMAGE_CODES["INVALID_OPTIONS"],
|
||||
`compress-image.invalid_options: ${issues}`
|
||||
);
|
||||
}
|
||||
return result.data as CompressOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level image compression orchestrator.
|
||||
*
|
||||
* `ImageCompressor` wraps a pluggable `ImageAdapter` and adds:
|
||||
* - Zod option validation before delegating to the adapter
|
||||
* - Dimension range checking via `validateDimensions`
|
||||
* - OTel counter and histogram instrumentation
|
||||
* - A `compressIfLarger` convenience method
|
||||
*
|
||||
* The default adapter is `MemoryAdapter`, which is safe for tests but should
|
||||
* be replaced with `SharpAdapter` in production via `setAdapter()` or by
|
||||
* passing an adapter to `createCompressor()`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { createCompressor } from '@suaveplan/compress-image/compressor';
|
||||
* import { SharpAdapter } from '@suaveplan/compress-image/adapters/sharp';
|
||||
* import sharp from 'sharp';
|
||||
*
|
||||
* const compressor = createCompressor(new SharpAdapter(sharp));
|
||||
* const result = await compressor.compress(buf, { format: 'webp', quality: 75 });
|
||||
* ```
|
||||
*/
|
||||
export class ImageCompressor {
|
||||
protected adapter: ImageAdapter;
|
||||
|
||||
constructor(adapter?: ImageAdapter) {
|
||||
this.adapter = adapter ?? new MemoryAdapter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the active adapter.
|
||||
*
|
||||
* @param adapter - New `ImageAdapter` instance to use for all subsequent operations.
|
||||
*/
|
||||
setAdapter(adapter: ImageAdapter): void {
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compress an image according to the provided options.
|
||||
*
|
||||
* Options are validated with Zod before the call is delegated to the adapter.
|
||||
* OTel metrics are emitted for every call — counter incremented with `format`
|
||||
* and `outcome` attributes; duration recorded in milliseconds.
|
||||
*
|
||||
* @param input - Raw image bytes.
|
||||
* @param options - Compression parameters (all optional).
|
||||
* @returns Compressed image result.
|
||||
* @throws {CompressImageError} On invalid options, unsupported format, or adapter failure.
|
||||
*/
|
||||
async compress(
|
||||
input: Uint8Array,
|
||||
options: CompressOptions = {}
|
||||
): Promise<CompressResult> {
|
||||
const validated = parseOptions(options);
|
||||
validateDimensions(validated.width, validated.height);
|
||||
|
||||
const start = Date.now();
|
||||
let outcome = "success";
|
||||
|
||||
try {
|
||||
const result = await this.adapter.compress(input, validated);
|
||||
return result;
|
||||
} catch (err) {
|
||||
outcome = "error";
|
||||
throw err;
|
||||
} finally {
|
||||
const format = validated.format ?? "unknown";
|
||||
const durationMs = Date.now() - start;
|
||||
getCounter().add(1, { format, outcome });
|
||||
getDuration().record(durationMs, { format, outcome });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract image metadata without performing any compression.
|
||||
*
|
||||
* @param input - Raw image bytes.
|
||||
* @returns Image metadata including dimensions, format, size, and alpha.
|
||||
* @throws {CompressImageError} On adapter failure or unrecognized format.
|
||||
*/
|
||||
async getInfo(input: Uint8Array): Promise<ImageInfo> {
|
||||
return this.adapter.getInfo(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize an image to the specified dimensions.
|
||||
*
|
||||
* @param input - Raw image bytes.
|
||||
* @param width - Target width in pixels (1–32767).
|
||||
* @param height - Target height in pixels (1–32767).
|
||||
* @param mode - Resize strategy. Default 'cover'.
|
||||
* @returns Resized image bytes.
|
||||
* @throws {CompressImageError} On invalid dimensions or adapter failure.
|
||||
*/
|
||||
async resize(
|
||||
input: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
mode?: ResizeMode
|
||||
): Promise<Uint8Array> {
|
||||
validateDimensions(width, height);
|
||||
return this.adapter.resize(input, width, height, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compress an image only if its byte length exceeds `maxBytes`.
|
||||
*
|
||||
* When the input is already small enough, this method returns a synthetic
|
||||
* `CompressResult` with `compressionRatio = 1` and the original bytes, so
|
||||
* callers get a uniform return type regardless of whether compression ran.
|
||||
*
|
||||
* @param input - Raw image bytes.
|
||||
* @param maxBytes - Size threshold in bytes.
|
||||
* @param options - Compression parameters forwarded to `compress()` when triggered.
|
||||
* @returns `CompressResult` — either the compression output or the original bytes.
|
||||
* @throws {CompressImageError} When compression is triggered and fails.
|
||||
*/
|
||||
async compressIfLarger(
|
||||
input: Uint8Array,
|
||||
maxBytes: number,
|
||||
options: CompressOptions = {}
|
||||
): Promise<CompressResult> {
|
||||
if (input.length <= maxBytes) {
|
||||
// Return the original as-is with neutral metadata
|
||||
const info = await this.adapter.getInfo(input);
|
||||
return {
|
||||
data: input,
|
||||
format: info.format,
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
originalSize: input.length,
|
||||
compressedSize: input.length,
|
||||
compressionRatio: 1,
|
||||
};
|
||||
}
|
||||
return this.compress(input, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function for `ImageCompressor`.
|
||||
*
|
||||
* Equivalent to `new ImageCompressor(adapter)` but more ergonomic in functional
|
||||
* contexts and easier to type with inference.
|
||||
*
|
||||
* @param adapter - Optional adapter. Defaults to `MemoryAdapter`.
|
||||
* @returns A configured `ImageCompressor` instance.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const compressor = createCompressor(); // uses MemoryAdapter
|
||||
* const compressor = createCompressor(new SharpAdapter(sharp));
|
||||
* ```
|
||||
*/
|
||||
export function createCompressor(adapter?: ImageAdapter): ImageCompressor {
|
||||
return new ImageCompressor(adapter);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createCompressor, ImageCompressor } from "./compressor";
|
||||
1
packages/media/image/compress-image/src/core/index.ts
Normal file
1
packages/media/image/compress-image/src/core/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createCompressor, ImageCompressor } from "./compressor";
|
||||
@@ -0,0 +1,82 @@
|
||||
# compress-image-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the structured error class and stable code registry for the `@suaveplan/compress-image` package. All compression failures are surfaced through `CompressImageError`, which extends the project-wide `SuaveplanError` base class to guarantee consistent wire format, cause-chain traversal, OTel span-event integration, and Zod-validated JSON/binary serialization.
|
||||
|
||||
Centralising codes in `COMPRESS_IMAGE_CODES` (produced by `defineCodes`) ensures that every error string is unique within the project namespace and will not silently change across package updates.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable `COMPRESS_IMAGE_CODES` registry with four codes covering all failure surfaces
|
||||
- `CompressImageError` class with typed `code` property for narrow `instanceof` discrimination
|
||||
- Full cause-chain forwarding via the `cause` option
|
||||
- Compatible with the `SuaveplanError` OTel adapter and redaction middleware
|
||||
- Zero external runtime dependencies beyond `@suaveplan/error`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CompressImageError, COMPRESS_IMAGE_CODES } from '@suaveplan/compress-image/errors';
|
||||
|
||||
throw new CompressImageError(
|
||||
'Unsupported input format: tiff',
|
||||
COMPRESS_IMAGE_CODES.UNSUPPORTED_FORMAT,
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { CompressImageError, COMPRESS_IMAGE_CODES } from '@suaveplan/compress-image/errors';
|
||||
|
||||
async function runCompress(input: Uint8Array) {
|
||||
try {
|
||||
await adapter.compress(input, { quality: 80 });
|
||||
} catch (err) {
|
||||
throw new CompressImageError(
|
||||
'Compression pipeline failed',
|
||||
COMPRESS_IMAGE_CODES.COMPRESS_FAILED,
|
||||
{ cause: err instanceof Error ? err : undefined },
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `COMPRESS_IMAGE_CODES`
|
||||
|
||||
An object map produced by `defineCodes('COMPRESS_IMAGE', { ... })`. Each value is a stable string code of the form `COMPRESS_IMAGE.<KEY>`.
|
||||
|
||||
| Key | Description |
|
||||
|---|---|
|
||||
| `UNSUPPORTED_FORMAT` | Input image format is not recognized or not supported by the adapter |
|
||||
| `COMPRESS_FAILED` | The compression operation itself threw an error |
|
||||
| `INVALID_OPTIONS` | Zod validation rejected the provided `CompressOptions` |
|
||||
| `ADAPTER_NOT_SET` | `compress` / `resize` / `getInfo` was called before an adapter was configured |
|
||||
|
||||
### `CompressImageError`
|
||||
|
||||
```ts
|
||||
class CompressImageError extends SuaveplanError {
|
||||
constructor(message: string, code: string, options?: { cause?: Error }): void
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters**
|
||||
|
||||
| Name | Type | Description |
|
||||
|---|---|---|
|
||||
| `message` | `string` | Human-readable description of the failure |
|
||||
| `code` | `string` | A value from `COMPRESS_IMAGE_CODES` |
|
||||
| `options.cause` | `Error \| undefined` | Originating error for cause-chain traversal |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SuaveplanError` stores `code` on its instance and enforces the Zod-validated wire format so errors survive cross-process transport. The `name = 'CompressImageError'` assignment enables `instanceof` checks even after deserialization with the error registry.
|
||||
|
||||
The constructor signature deviates slightly from `DatetimeError` — it accepts a plain `string` message rather than a messageKey because image compression errors carry enough context inline and do not require i18n resolution.
|
||||
@@ -0,0 +1,79 @@
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
COMPRESS_IMAGE_CODES,
|
||||
CompressImageError,
|
||||
} from "./compress-image-error.js";
|
||||
|
||||
describe("COMPRESS_IMAGE_CODES", () => {
|
||||
it("contains all four expected codes", { timeout: 5000 }, () => {
|
||||
expect(Object.keys(COMPRESS_IMAGE_CODES)).toEqual([
|
||||
"UNSUPPORTED_FORMAT",
|
||||
"COMPRESS_FAILED",
|
||||
"INVALID_OPTIONS",
|
||||
"ADAPTER_NOT_SET",
|
||||
]);
|
||||
});
|
||||
|
||||
it("each code string starts with COMPRESS_IMAGE.", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const value of Object.values(COMPRESS_IMAGE_CODES)) {
|
||||
expect(value).toMatch(/^COMPRESS_IMAGE\./);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompressImageError", () => {
|
||||
it("extends SuaveplanError", { timeout: 5000 }, () => {
|
||||
const err = new CompressImageError(
|
||||
COMPRESS_IMAGE_CODES.COMPRESS_FAILED,
|
||||
"compress-image.test"
|
||||
);
|
||||
expect(err).toBeInstanceOf(SuaveplanError);
|
||||
});
|
||||
|
||||
it("has name CompressImageError", { timeout: 5000 }, () => {
|
||||
const err = new CompressImageError(
|
||||
COMPRESS_IMAGE_CODES.INVALID_OPTIONS,
|
||||
"compress-image.test"
|
||||
);
|
||||
expect(err.name).toBe("CompressImageError");
|
||||
});
|
||||
|
||||
it("stores the messageKey as message", { timeout: 5000 }, () => {
|
||||
const err = new CompressImageError(
|
||||
COMPRESS_IMAGE_CODES.COMPRESS_FAILED,
|
||||
"compress-image.something_failed"
|
||||
);
|
||||
expect(err.message).toBe("compress-image.something_failed");
|
||||
});
|
||||
|
||||
it("stores the code", { timeout: 5000 }, () => {
|
||||
const err = new CompressImageError(
|
||||
COMPRESS_IMAGE_CODES.UNSUPPORTED_FORMAT,
|
||||
"compress-image.test"
|
||||
);
|
||||
expect(err.code).toBe(COMPRESS_IMAGE_CODES.UNSUPPORTED_FORMAT);
|
||||
});
|
||||
|
||||
it("forwards cause", { timeout: 5000 }, () => {
|
||||
const cause = new Error("original");
|
||||
const err = new CompressImageError(
|
||||
COMPRESS_IMAGE_CODES.COMPRESS_FAILED,
|
||||
"compress-image.test",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("works without cause option", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
() =>
|
||||
new CompressImageError(
|
||||
COMPRESS_IMAGE_CODES.ADAPTER_NOT_SET,
|
||||
"compress-image.test"
|
||||
)
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error code registry for `@suaveplan/compress-image`.
|
||||
*
|
||||
* Codes are namespaced under `COMPRESS_IMAGE` for easy identification in
|
||||
* structured error logs and OTel span events.
|
||||
*/
|
||||
export const COMPRESS_IMAGE_CODES = defineCodes("COMPRESS_IMAGE", [
|
||||
"UNSUPPORTED_FORMAT",
|
||||
"COMPRESS_FAILED",
|
||||
"INVALID_OPTIONS",
|
||||
"ADAPTER_NOT_SET",
|
||||
] as const);
|
||||
|
||||
export type CompressImageCode =
|
||||
(typeof COMPRESS_IMAGE_CODES)[keyof typeof COMPRESS_IMAGE_CODES];
|
||||
|
||||
/**
|
||||
* Typed error for all `@suaveplan/compress-image` failures.
|
||||
*
|
||||
* Extends `SuaveplanError` with a stable code drawn from `COMPRESS_IMAGE_CODES`.
|
||||
* Callers can discriminate on `error.code` to handle specific failure modes.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { CompressImageError, COMPRESS_IMAGE_CODES } from '@suaveplan/compress-image/errors';
|
||||
*
|
||||
* throw new CompressImageError(
|
||||
* COMPRESS_IMAGE_CODES.UNSUPPORTED_FORMAT,
|
||||
* 'compress.unsupported_format',
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export class CompressImageError extends SuaveplanError<CompressImageCode> {
|
||||
public constructor(
|
||||
code: CompressImageCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "CompressImageError";
|
||||
}
|
||||
}
|
||||
5
packages/media/image/compress-image/src/errors/index.ts
Normal file
5
packages/media/image/compress-image/src/errors/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
COMPRESS_IMAGE_CODES,
|
||||
type CompressImageCode,
|
||||
CompressImageError,
|
||||
} from "./compress-image-error";
|
||||
43
packages/media/image/compress-image/src/index.ts
Normal file
43
packages/media/image/compress-image/src/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Types
|
||||
|
||||
// Adapters
|
||||
export { MemoryAdapter } from "./adapters/memory/memory.js";
|
||||
export type {
|
||||
SharpInstance,
|
||||
SharpLike,
|
||||
} from "./adapters/sharp/sharp.js";
|
||||
export { SharpAdapter } from "./adapters/sharp/sharp.js";
|
||||
// Core
|
||||
export {
|
||||
createCompressor,
|
||||
ImageCompressor,
|
||||
} from "./core/compressor/compressor.js";
|
||||
export type { CompressImageCode } from "./errors/compress-image-error.js";
|
||||
// Errors
|
||||
export {
|
||||
COMPRESS_IMAGE_CODES,
|
||||
CompressImageError,
|
||||
} from "./errors/compress-image-error.js";
|
||||
export type {
|
||||
CompressOptionsInput,
|
||||
CompressOptionsParsed,
|
||||
} from "./schemas/compress-options.schema.js";
|
||||
// Schemas
|
||||
export { CompressOptionsSchema } from "./schemas/compress-options.schema.js";
|
||||
export type {
|
||||
ColorSpace,
|
||||
CompressOptions,
|
||||
CompressResult,
|
||||
ImageAdapter,
|
||||
ImageFormat,
|
||||
ImageInfo,
|
||||
ResizeMode,
|
||||
} from "./types/compress-image.types.js";
|
||||
// Utils
|
||||
export {
|
||||
calculateAspectRatio,
|
||||
detectFormat,
|
||||
parseJpegDimensions,
|
||||
parsePngDimensions,
|
||||
validateDimensions,
|
||||
} from "./utils/image-utils.js";
|
||||
@@ -0,0 +1,103 @@
|
||||
# compress-options.schema
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides a Zod schema (`CompressOptionsSchema`) that validates and normalises
|
||||
`CompressOptions` at runtime before any image-processing work begins. Keeping
|
||||
validation in a dedicated schema file separates concerns cleanly: the
|
||||
compressor delegates option-checking to this module and never needs to repeat
|
||||
range guards inline.
|
||||
|
||||
## Features
|
||||
|
||||
- `format` — optional enum restricted to `"jpeg" | "png" | "webp" | "avif"`
|
||||
- `quality` — integer `1–100`; rejects floats and out-of-range values
|
||||
- `width` / `height` — positive integers capped at 32767 (JPEG/PNG spec limit)
|
||||
- `resizeMode` — optional enum of five Sharp `fit` strategies
|
||||
- `stripMetadata` / `progressive` / `lossless` — optional booleans
|
||||
- `effort` — integer `0–6` for compression effort / speed trade-off
|
||||
- Re-exports `CompressOptionsInput` (pre-parse) and `CompressOptionsParsed` (post-parse) type aliases for strong typing at call sites
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CompressOptionsSchema } from '@suaveplan/compress-image/schemas';
|
||||
|
||||
const result = CompressOptionsSchema.safeParse({ quality: 85, format: 'webp' });
|
||||
if (!result.success) {
|
||||
console.error(result.error.issues);
|
||||
} else {
|
||||
const opts = result.data; // CompressOptionsParsed
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { CompressOptionsSchema } from '@suaveplan/compress-image/schemas';
|
||||
import type { CompressOptionsInput } from '@suaveplan/compress-image/schemas';
|
||||
|
||||
function toCompressOptions(raw: unknown): CompressOptionsInput {
|
||||
// Strict parse — throws ZodError on invalid input
|
||||
return CompressOptionsSchema.parse(raw);
|
||||
}
|
||||
|
||||
// Combining with ImageCompressor validation:
|
||||
// The ImageCompressor calls CompressOptionsSchema.safeParse internally and
|
||||
// maps ZodError issues into a structured CompressImageError(INVALID_OPTIONS).
|
||||
// Direct use of this schema is useful in API route handlers that want to
|
||||
// validate options before constructing an ImageCompressor.
|
||||
const validated = CompressOptionsSchema.safeParse({
|
||||
format: 'avif',
|
||||
quality: 60,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
resizeMode: 'cover',
|
||||
effort: 4,
|
||||
stripMetadata: true,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `CompressOptionsSchema`
|
||||
|
||||
`ZodObject` schema validating `CompressOptions`. All fields are optional.
|
||||
|
||||
| Field | Type | Constraints |
|
||||
|----------------|-----------------------------------|----------------------------|
|
||||
| `format` | `"jpeg" \| "png" \| "webp" \| "avif"` | enum |
|
||||
| `quality` | `number` | int, 1–100 |
|
||||
| `width` | `number` | int, 1–32767 |
|
||||
| `height` | `number` | int, 1–32767 |
|
||||
| `resizeMode` | `ResizeMode` | enum of 5 strategies |
|
||||
| `stripMetadata`| `boolean` | optional |
|
||||
| `progressive` | `boolean` | optional |
|
||||
| `lossless` | `boolean` | optional |
|
||||
| `effort` | `number` | int, 0–6 |
|
||||
|
||||
### `CompressOptionsInput`
|
||||
|
||||
`z.input<typeof CompressOptionsSchema>` — the shape of data accepted before parsing (all fields optional).
|
||||
|
||||
### `CompressOptionsParsed`
|
||||
|
||||
`z.output<typeof CompressOptionsSchema>` — the shape returned after successful parsing.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema intentionally mirrors the `CompressOptions` interface defined in
|
||||
`compress-image.types.ts`. No coercion is applied; callers are expected to
|
||||
pass the correct JS types. Integer constraints (`z.number().int()`) are
|
||||
necessary because Sharp interprets quality as a C `int`; passing `80.5` would
|
||||
produce unpredictable output.
|
||||
|
||||
Effort range `0–6` follows the WebP/AVIF encoder convention. Sharp maps this
|
||||
field directly to the codec's speed parameter, where 0 is fastest (lower
|
||||
quality at equal bit-rate) and 6 is slowest (best compression).
|
||||
|
||||
The schema is intentionally stateless and side-effect-free — it is safe to
|
||||
import and reuse across workers, edge functions, and test helpers.
|
||||
@@ -0,0 +1,44 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod schema for validating `CompressOptions` at runtime.
|
||||
*
|
||||
* All fields are optional. Out-of-range values are rejected with
|
||||
* descriptive error messages so callers get actionable feedback.
|
||||
*/
|
||||
export const CompressOptionsSchema = z.object({
|
||||
format: z.enum(["jpeg", "png", "webp", "avif"]).optional(),
|
||||
quality: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, "quality must be between 1 and 100")
|
||||
.max(100, "quality must be between 1 and 100")
|
||||
.optional(),
|
||||
width: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, "width must be a positive integer")
|
||||
.max(32767, "width must not exceed 32767 pixels")
|
||||
.optional(),
|
||||
height: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, "height must be a positive integer")
|
||||
.max(32767, "height must not exceed 32767 pixels")
|
||||
.optional(),
|
||||
resizeMode: z
|
||||
.enum(["cover", "contain", "fill", "inside", "outside"])
|
||||
.optional(),
|
||||
stripMetadata: z.boolean().optional(),
|
||||
progressive: z.boolean().optional(),
|
||||
lossless: z.boolean().optional(),
|
||||
effort: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0, "effort must be between 0 and 6")
|
||||
.max(6, "effort must be between 0 and 6")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type CompressOptionsInput = z.input<typeof CompressOptionsSchema>;
|
||||
export type CompressOptionsParsed = z.output<typeof CompressOptionsSchema>;
|
||||
5
packages/media/image/compress-image/src/schemas/index.ts
Normal file
5
packages/media/image/compress-image/src/schemas/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type CompressOptionsInput,
|
||||
type CompressOptionsParsed,
|
||||
CompressOptionsSchema,
|
||||
} from "./compress-options.schema";
|
||||
@@ -0,0 +1,149 @@
|
||||
# compress-image.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines all public TypeScript types and interfaces for the
|
||||
`@suaveplan/compress-image` package. Keeping type definitions in a single
|
||||
dedicated file avoids circular imports between adapter and compressor modules
|
||||
and makes it straightforward for consumers to import only the types they need
|
||||
without pulling in any runtime code.
|
||||
|
||||
## Features
|
||||
|
||||
- `ImageFormat` — union of four supported output formats (`jpeg`, `png`, `webp`, `avif`)
|
||||
- `ResizeMode` — five resize strategies mirroring the Sharp `fit` option
|
||||
- `ColorSpace` — two colour-space choices (`srgb`, `p3`)
|
||||
- `CompressOptions` — all parameters accepted by `ImageCompressor.compress()`
|
||||
- `CompressResult` — structured result returned after a compress call
|
||||
- `ImageInfo` — image metadata returned by `getInfo()` without modifying the image
|
||||
- `ImageAdapter` — pluggable backend interface implemented by `MemoryAdapter` and `SharpAdapter`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
CompressOptions,
|
||||
CompressResult,
|
||||
ImageFormat,
|
||||
} from '@suaveplan/compress-image';
|
||||
|
||||
function makeWebpOptions(quality: number): CompressOptions {
|
||||
return { format: 'webp' as ImageFormat, quality };
|
||||
}
|
||||
|
||||
async function printResult(result: CompressResult): Promise<void> {
|
||||
console.log(`Compressed to ${result.compressedSize} bytes (ratio ${result.compressionRatio.toFixed(2)})`);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { ImageAdapter, CompressOptions, CompressResult, ImageInfo, ResizeMode } from '@suaveplan/compress-image';
|
||||
|
||||
// Implement a custom adapter backed by a WASM codec:
|
||||
class WasmAdapter implements ImageAdapter {
|
||||
async compress(input: Uint8Array, options: CompressOptions): Promise<CompressResult> {
|
||||
// delegate to WASM encoder...
|
||||
return {
|
||||
data: input,
|
||||
format: options.format ?? 'jpeg',
|
||||
width: 100,
|
||||
height: 100,
|
||||
originalSize: input.length,
|
||||
compressedSize: input.length,
|
||||
compressionRatio: 1,
|
||||
};
|
||||
}
|
||||
|
||||
async getInfo(input: Uint8Array): Promise<ImageInfo> {
|
||||
return { width: 100, height: 100, format: 'jpeg', size: input.length, hasAlpha: false };
|
||||
}
|
||||
|
||||
async resize(input: Uint8Array, _w: number, _h: number, _mode?: ResizeMode): Promise<Uint8Array> {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ImageFormat`
|
||||
|
||||
```ts
|
||||
type ImageFormat = "jpeg" | "png" | "webp" | "avif";
|
||||
```
|
||||
|
||||
### `ResizeMode`
|
||||
|
||||
```ts
|
||||
type ResizeMode = "cover" | "contain" | "fill" | "inside" | "outside";
|
||||
```
|
||||
|
||||
Mirrors the Sharp `fit` option. `cover` crops to fill; `contain` letterboxes; `fill` stretches; `inside` shrinks only; `outside` enlarges only.
|
||||
|
||||
### `ColorSpace`
|
||||
|
||||
```ts
|
||||
type ColorSpace = "srgb" | "p3";
|
||||
```
|
||||
|
||||
### `CompressOptions`
|
||||
|
||||
All fields optional.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------------|---------------|----------------------------------------------------------|
|
||||
| `format` | `ImageFormat` | Target format. Defaults to detected input format. |
|
||||
| `quality` | `number` | 1–100 quality level. Default 80. |
|
||||
| `width` | `number` | Target width in pixels. |
|
||||
| `height` | `number` | Target height in pixels. |
|
||||
| `resizeMode` | `ResizeMode` | Resize strategy. Default `"cover"`. |
|
||||
| `stripMetadata` | `boolean` | Strip EXIF and other metadata. Default `true`. |
|
||||
| `progressive` | `boolean` | Enable progressive JPEG. Default `false`. |
|
||||
| `lossless` | `boolean` | WebP lossless encoding. Default `false`. |
|
||||
| `effort` | `number` | Compression effort 0–6. Default 4. |
|
||||
|
||||
### `CompressResult`
|
||||
|
||||
| Field | Type | Description |
|
||||
|--------------------|---------------|------------------------------------------------------|
|
||||
| `data` | `Uint8Array` | Compressed image bytes. |
|
||||
| `format` | `ImageFormat` | Output format. |
|
||||
| `width` | `number` | Output width in pixels. |
|
||||
| `height` | `number` | Output height in pixels. |
|
||||
| `originalSize` | `number` | Input byte length. |
|
||||
| `compressedSize` | `number` | Output byte length. |
|
||||
| `compressionRatio` | `number` | `originalSize / compressedSize` (>1 = effective compression). |
|
||||
|
||||
### `ImageInfo`
|
||||
|
||||
| Field | Type | Description |
|
||||
|------------|---------------|------------------------------------------|
|
||||
| `width` | `number` | Image width in pixels. |
|
||||
| `height` | `number` | Image height in pixels. |
|
||||
| `format` | `ImageFormat` | Detected image format. |
|
||||
| `size` | `number` | File size in bytes. |
|
||||
| `hasAlpha` | `boolean` | Whether the image has an alpha channel. |
|
||||
|
||||
### `ImageAdapter`
|
||||
|
||||
Interface for pluggable image-processing backends.
|
||||
|
||||
| Method | Signature | Description |
|
||||
|-------------|---------------------------------------------------------------------------|-----------------------------------|
|
||||
| `compress` | `(input: Uint8Array, options: CompressOptions) => Promise<CompressResult>`| Compress with given options. |
|
||||
| `getInfo` | `(input: Uint8Array) => Promise<ImageInfo>` | Extract metadata without encoding.|
|
||||
| `resize` | `(input: Uint8Array, w: number, h: number, mode?: ResizeMode) => Promise<Uint8Array>` | Resize to target dimensions. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
These types are pure TypeScript interfaces — they emit no runtime code and add
|
||||
no bundle weight. The `ImageAdapter` interface is the primary extension point:
|
||||
any backend (Sharp, WebAssembly, Canvas API, remote service) that satisfies
|
||||
the three-method contract can be plugged into `ImageCompressor` via the
|
||||
`setAdapter()` method or the `createCompressor()` factory.
|
||||
|
||||
`ColorSpace` is defined here for forward compatibility but is not yet consumed
|
||||
by any built-in adapter. It is available for custom adapters that support
|
||||
wide-gamut output.
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Supported image output formats.
|
||||
*
|
||||
* - `jpeg` — lossy, no alpha, widely supported
|
||||
* - `png` — lossless, alpha support, larger files
|
||||
* - `webp` — modern format with both lossy and lossless modes
|
||||
* - `avif` — next-generation format, excellent compression
|
||||
*/
|
||||
export type ImageFormat = "jpeg" | "png" | "webp" | "avif";
|
||||
|
||||
/**
|
||||
* Controls how an image is resized when both width and height are specified.
|
||||
*
|
||||
* - `cover` — crop to fill the target dimensions (default)
|
||||
* - `contain` — letterbox to fit within the target dimensions
|
||||
* - `fill` — stretch to exactly fill the target dimensions
|
||||
* - `inside` — shrink to fit within the target dimensions, never enlarge
|
||||
* - `outside` — resize to cover the target dimensions, never shrink
|
||||
*/
|
||||
export type ResizeMode = "cover" | "contain" | "fill" | "inside" | "outside";
|
||||
|
||||
/**
|
||||
* Color space for the output image.
|
||||
*
|
||||
* - `srgb` — standard RGB (default)
|
||||
* - `p3` — Display P3 wide gamut
|
||||
*/
|
||||
export type ColorSpace = "srgb" | "p3";
|
||||
|
||||
/**
|
||||
* Options controlling the compression operation.
|
||||
*/
|
||||
export interface CompressOptions {
|
||||
/** Target output format. Defaults to the same as the input format. */
|
||||
format?: ImageFormat;
|
||||
/** Quality level from 1 (lowest) to 100 (highest). Default 80. */
|
||||
quality?: number;
|
||||
/** Target width in pixels. */
|
||||
width?: number;
|
||||
/** Target height in pixels. */
|
||||
height?: number;
|
||||
/** How to resize when both width and height are specified. Default 'cover'. */
|
||||
resizeMode?: ResizeMode;
|
||||
/** Strip EXIF and other metadata. Default true. */
|
||||
stripMetadata?: boolean;
|
||||
/** Enable progressive JPEG encoding. Default false. */
|
||||
progressive?: boolean;
|
||||
/** Use WebP lossless encoding. Default false. */
|
||||
lossless?: boolean;
|
||||
/** Compression effort from 0 (fastest) to 6 (best). Default 4. */
|
||||
effort?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a compression operation.
|
||||
*/
|
||||
export interface CompressResult {
|
||||
/** The compressed image bytes. */
|
||||
data: Uint8Array;
|
||||
/** The output format. */
|
||||
format: ImageFormat;
|
||||
/** Output image width in pixels. */
|
||||
width: number;
|
||||
/** Output image height in pixels. */
|
||||
height: number;
|
||||
/** Original input size in bytes. */
|
||||
originalSize: number;
|
||||
/** Compressed output size in bytes. */
|
||||
compressedSize: number;
|
||||
/** Ratio of original to compressed size (>1 means compression was effective). */
|
||||
compressionRatio: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata about an image without performing compression.
|
||||
*/
|
||||
export interface ImageInfo {
|
||||
/** Image width in pixels. */
|
||||
width: number;
|
||||
/** Image height in pixels. */
|
||||
height: number;
|
||||
/** Detected image format. */
|
||||
format: ImageFormat;
|
||||
/** File size in bytes. */
|
||||
size: number;
|
||||
/** Whether the image has an alpha channel. */
|
||||
hasAlpha: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluggable adapter interface for image processing backends.
|
||||
*
|
||||
* Implementations must handle format detection, dimension extraction,
|
||||
* quality reduction, and resizing. The `MemoryAdapter` ships for testing;
|
||||
* `SharpAdapter` wraps Sharp for production Node.js use.
|
||||
*/
|
||||
export interface ImageAdapter {
|
||||
/**
|
||||
* Compress an image according to the given options.
|
||||
*
|
||||
* @param input - Raw image bytes.
|
||||
* @param options - Compression parameters.
|
||||
* @returns Compressed image result with metadata.
|
||||
*/
|
||||
compress(
|
||||
input: Uint8Array,
|
||||
options: CompressOptions
|
||||
): Promise<CompressResult>;
|
||||
|
||||
/**
|
||||
* Extract metadata from an image without compressing it.
|
||||
*
|
||||
* @param input - Raw image bytes.
|
||||
* @returns Image dimensions, format, size, and alpha channel presence.
|
||||
*/
|
||||
getInfo(input: Uint8Array): Promise<ImageInfo>;
|
||||
|
||||
/**
|
||||
* Resize an image to the given dimensions.
|
||||
*
|
||||
* @param input - Raw image bytes.
|
||||
* @param width - Target width in pixels.
|
||||
* @param height - Target height in pixels.
|
||||
* @param mode - Resize strategy. Default 'cover'.
|
||||
* @returns Resized image bytes.
|
||||
*/
|
||||
resize(
|
||||
input: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
mode?: ResizeMode
|
||||
): Promise<Uint8Array>;
|
||||
}
|
||||
9
packages/media/image/compress-image/src/types/index.ts
Normal file
9
packages/media/image/compress-image/src/types/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
ColorSpace,
|
||||
CompressOptions,
|
||||
CompressResult,
|
||||
ImageAdapter,
|
||||
ImageFormat,
|
||||
ImageInfo,
|
||||
ResizeMode,
|
||||
} from "./compress-image.types";
|
||||
89
packages/media/image/compress-image/src/utils/image-utils.md
Normal file
89
packages/media/image/compress-image/src/utils/image-utils.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# image-utils
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides low-level, dependency-free utility functions for working with raw image bytes. These utilities power both the `MemoryAdapter` (testing) and the `SharpAdapter` (production), and are also available to application code for format detection and dimension extraction without instantiating a full adapter.
|
||||
|
||||
All functions operate on `Uint8Array` buffers and are designed to be pure and free of side effects, enabling tree-shaking and straightforward unit testing.
|
||||
|
||||
## Features
|
||||
|
||||
- Magic-byte format detection for JPEG, PNG, WebP, and AVIF
|
||||
- PNG IHDR chunk dimension parser (big-endian uint32 at fixed offsets)
|
||||
- JPEG SOF0/SOF2 marker scanner for baseline and progressive dimension extraction
|
||||
- Aspect ratio calculation using the Euclidean GCD algorithm
|
||||
- Dimension range validation with descriptive `CompressImageError` on failure
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { detectFormat, parsePngDimensions } from '@suaveplan/compress-image';
|
||||
|
||||
const format = detectFormat(buffer); // 'png' | 'jpeg' | 'webp' | 'avif' | null
|
||||
|
||||
const dims = parsePngDimensions(buffer);
|
||||
if (dims) {
|
||||
console.log(dims.width, dims.height);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
detectFormat,
|
||||
parseJpegDimensions,
|
||||
validateDimensions,
|
||||
calculateAspectRatio,
|
||||
} from '@suaveplan/compress-image';
|
||||
|
||||
function analyzeImage(data: Uint8Array) {
|
||||
const format = detectFormat(data);
|
||||
if (!format) throw new Error('Unknown format');
|
||||
|
||||
let dims: { width: number; height: number } | null = null;
|
||||
if (format === 'jpeg') dims = parseJpegDimensions(data);
|
||||
else if (format === 'png') dims = parsePngDimensions(data);
|
||||
|
||||
if (!dims) throw new Error('Could not determine dimensions');
|
||||
|
||||
// Validate target dimensions before a resize
|
||||
validateDimensions(800, 600);
|
||||
|
||||
const ratio = calculateAspectRatio(dims.width, dims.height);
|
||||
console.log(`Aspect ratio: ${ratio.width}:${ratio.height}`);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `detectFormat(data: Uint8Array): ImageFormat | null`
|
||||
|
||||
Inspects magic bytes at the beginning of `data` to determine the image format. Returns `null` for unrecognized signatures. Requires at least 4 bytes; WebP and AVIF detection requires 12 bytes.
|
||||
|
||||
### `validateDimensions(width?: number, height?: number): void`
|
||||
|
||||
Validates that the provided dimensions (when present) are positive integers not exceeding 32767. Throws `CompressImageError` with code `INVALID_OPTIONS` on any violation.
|
||||
|
||||
### `calculateAspectRatio(width: number, height: number): { width: number; height: number }`
|
||||
|
||||
Returns the reduced aspect ratio using the Euclidean GCD. For example `(1920, 1080)` returns `{ width: 16, height: 9 }`.
|
||||
|
||||
### `parsePngDimensions(data: Uint8Array): { width: number; height: number } | null`
|
||||
|
||||
Reads the IHDR chunk at the fixed offset in a PNG file (bytes 16–23) and returns the width and height. Returns `null` if the magic bytes are wrong, the buffer is too short, or the IHDR tag is not found.
|
||||
|
||||
### `parseJpegDimensions(data: Uint8Array): { width: number; height: number } | null`
|
||||
|
||||
Scans the JPEG segment stream for SOF0 (0xFF 0xC0) or SOF2 (0xFF 0xC2) markers and reads the frame header dimensions. Returns `null` if no SOF marker is found or the buffer is too short.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The AVIF detection reads the `ftyp` box major brand field at bytes 8–11. This covers `avif` (still image) and `avis` (AVIF image sequence). Files with compatible brands listed only in the compatible-brands array (not the major brand) are not detected; this is an acceptable limitation for a magic-byte heuristic.
|
||||
|
||||
`parseJpegDimensions` handles 0xFF padding bytes and segment-length arithmetic correctly, but does not attempt to parse progressive scans beyond SOF2. DNL (Define Number of Lines) segments used in some streaming encoders are not supported.
|
||||
|
||||
Dimension overflow: width and height are read as unsigned 32-bit big-endian integers and immediately checked against 0 to reject pathological inputs before they can propagate.
|
||||
@@ -0,0 +1,542 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CompressImageError } from "../errors/compress-image-error.js";
|
||||
import {
|
||||
calculateAspectRatio,
|
||||
detectFormat,
|
||||
parseJpegDimensions,
|
||||
parsePngDimensions,
|
||||
validateDimensions,
|
||||
} from "./image-utils.js";
|
||||
|
||||
// ── Test fixtures ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Minimal valid PNG (1×1 pixel, magic + IHDR chunk header). */
|
||||
const MINIMAL_PNG = new Uint8Array([
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a, // magic
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0d, // IHDR length
|
||||
0x49,
|
||||
0x48,
|
||||
0x44,
|
||||
0x52, // "IHDR"
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01, // width = 1
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01, // height = 1
|
||||
0x08,
|
||||
0x02,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // bit depth, color type, etc.
|
||||
0x90,
|
||||
0x77,
|
||||
0x53,
|
||||
0xde, // CRC
|
||||
]);
|
||||
|
||||
/** PNG with explicit 16×32 dimensions. */
|
||||
const PNG_16x32 = new Uint8Array([
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0d,
|
||||
0x49,
|
||||
0x48,
|
||||
0x44,
|
||||
0x52,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x10, // width = 16
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x20, // height = 32
|
||||
0x08,
|
||||
0x02,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
]);
|
||||
|
||||
/** Minimal JPEG with SOF0 marker encoding a 16×16 image. */
|
||||
const MINIMAL_JPEG = new Uint8Array([
|
||||
0xff,
|
||||
0xd8, // SOI
|
||||
0xff,
|
||||
0xe0, // APP0 marker
|
||||
0x00,
|
||||
0x10, // APP0 length = 16
|
||||
0x4a,
|
||||
0x46,
|
||||
0x49,
|
||||
0x46,
|
||||
0x00, // "JFIF\0"
|
||||
0x01,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00, // JFIF data
|
||||
0xff,
|
||||
0xc0, // SOF0 marker
|
||||
0x00,
|
||||
0x0b, // SOF0 length
|
||||
0x08, // precision
|
||||
0x00,
|
||||
0x10, // height = 16
|
||||
0x00,
|
||||
0x10, // width = 16
|
||||
0x01, // components
|
||||
]);
|
||||
|
||||
/** JPEG with progressive SOF2 marker (32×64). */
|
||||
const PROGRESSIVE_JPEG = new Uint8Array([
|
||||
0xff,
|
||||
0xd8,
|
||||
0xff,
|
||||
0xe0,
|
||||
0x00,
|
||||
0x10,
|
||||
0x4a,
|
||||
0x46,
|
||||
0x49,
|
||||
0x46,
|
||||
0x00,
|
||||
0x01,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0xff,
|
||||
0xc2, // SOF2 (progressive)
|
||||
0x00,
|
||||
0x0b,
|
||||
0x08,
|
||||
0x00,
|
||||
0x20, // height = 32
|
||||
0x00,
|
||||
0x40, // width = 64
|
||||
0x01,
|
||||
]);
|
||||
|
||||
/** Minimal WebP (RIFF header + WEBP marker). */
|
||||
const MINIMAL_WEBP = new Uint8Array([
|
||||
0x52,
|
||||
0x49,
|
||||
0x46,
|
||||
0x46, // "RIFF"
|
||||
0x24,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // file size
|
||||
0x57,
|
||||
0x45,
|
||||
0x42,
|
||||
0x50, // "WEBP"
|
||||
]);
|
||||
|
||||
/** Minimal AVIF ftyp box. */
|
||||
const MINIMAL_AVIF = new Uint8Array([
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x1c, // box size
|
||||
0x66,
|
||||
0x74,
|
||||
0x79,
|
||||
0x70, // "ftyp"
|
||||
0x61,
|
||||
0x76,
|
||||
0x69,
|
||||
0x66, // major brand = "avif"
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // minor version
|
||||
]);
|
||||
|
||||
// ── detectFormat ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("detectFormat", () => {
|
||||
it("detects JPEG", { timeout: 5000 }, () => {
|
||||
expect(detectFormat(MINIMAL_JPEG)).toBe("jpeg");
|
||||
});
|
||||
|
||||
it("detects PNG", { timeout: 5000 }, () => {
|
||||
expect(detectFormat(MINIMAL_PNG)).toBe("png");
|
||||
});
|
||||
|
||||
it("detects WebP", { timeout: 5000 }, () => {
|
||||
expect(detectFormat(MINIMAL_WEBP)).toBe("webp");
|
||||
});
|
||||
|
||||
it("detects AVIF", { timeout: 5000 }, () => {
|
||||
expect(detectFormat(MINIMAL_AVIF)).toBe("avif");
|
||||
});
|
||||
|
||||
it("returns null for unknown format", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
detectFormat(new Uint8Array([0x00, 0x01, 0x02, 0x03]))
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty buffer", { timeout: 5000 }, () => {
|
||||
expect(detectFormat(new Uint8Array([]))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for too-short buffer", { timeout: 5000 }, () => {
|
||||
expect(detectFormat(new Uint8Array([0x89, 0x50]))).toBeNull();
|
||||
});
|
||||
|
||||
it("detects AVIF with 'avis' brand", { timeout: 5000 }, () => {
|
||||
const avis = new Uint8Array([
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x1c,
|
||||
0x66,
|
||||
0x74,
|
||||
0x79,
|
||||
0x70, // "ftyp"
|
||||
0x61,
|
||||
0x76,
|
||||
0x69,
|
||||
0x73, // "avis"
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
]);
|
||||
expect(detectFormat(avis)).toBe("avif");
|
||||
});
|
||||
|
||||
it("does not detect AVIF for unknown ftyp brand", { timeout: 5000 }, () => {
|
||||
const unknown = new Uint8Array([
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x1c,
|
||||
0x66,
|
||||
0x74,
|
||||
0x79,
|
||||
0x70,
|
||||
0x68,
|
||||
0x65,
|
||||
0x69,
|
||||
0x63, // "heic"
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
]);
|
||||
expect(detectFormat(unknown)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for 12-byte buffer without ftyp box (box=false branch)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// data.length >= 12 but bytes 4-7 are NOT "ftyp" → box = false → if(box) is false
|
||||
const notFtyp = new Uint8Array([
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x1c,
|
||||
0x00, // NOT 'f'
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
]);
|
||||
expect(detectFormat(notFtyp)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── validateDimensions ────────────────────────────────────────────────────────
|
||||
|
||||
describe("validateDimensions", () => {
|
||||
it("accepts valid width and height", { timeout: 5000 }, () => {
|
||||
expect(() => validateDimensions(100, 200)).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts undefined dimensions", { timeout: 5000 }, () => {
|
||||
expect(() => validateDimensions()).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts only width", { timeout: 5000 }, () => {
|
||||
expect(() => validateDimensions(800)).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts only height", { timeout: 5000 }, () => {
|
||||
expect(() => validateDimensions(undefined, 600)).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts max dimension 32767", { timeout: 5000 }, () => {
|
||||
expect(() => validateDimensions(32767, 32767)).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws for width = 0", { timeout: 5000 }, () => {
|
||||
expect(() => validateDimensions(0)).toThrow(CompressImageError);
|
||||
});
|
||||
|
||||
it("throws for negative width", { timeout: 5000 }, () => {
|
||||
expect(() => validateDimensions(-1)).toThrow(CompressImageError);
|
||||
});
|
||||
|
||||
it("throws for width > 32767", { timeout: 5000 }, () => {
|
||||
expect(() => validateDimensions(32768)).toThrow(CompressImageError);
|
||||
});
|
||||
|
||||
it("throws for height = 0", { timeout: 5000 }, () => {
|
||||
expect(() => validateDimensions(undefined, 0)).toThrow(
|
||||
CompressImageError
|
||||
);
|
||||
});
|
||||
|
||||
it("throws for height > 32767", { timeout: 5000 }, () => {
|
||||
expect(() => validateDimensions(undefined, 32768)).toThrow(
|
||||
CompressImageError
|
||||
);
|
||||
});
|
||||
|
||||
it("throws for non-integer width", { timeout: 5000 }, () => {
|
||||
expect(() => validateDimensions(1.5)).toThrow(CompressImageError);
|
||||
});
|
||||
|
||||
it("throws for non-integer height", { timeout: 5000 }, () => {
|
||||
expect(() => validateDimensions(100, 1.5)).toThrow(CompressImageError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── calculateAspectRatio ──────────────────────────────────────────────────────
|
||||
|
||||
describe("calculateAspectRatio", () => {
|
||||
it("reduces 1920x1080 to 16:9", { timeout: 5000 }, () => {
|
||||
expect(calculateAspectRatio(1920, 1080)).toEqual({
|
||||
width: 16,
|
||||
height: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("reduces 800x600 to 4:3", { timeout: 5000 }, () => {
|
||||
expect(calculateAspectRatio(800, 600)).toEqual({ width: 4, height: 3 });
|
||||
});
|
||||
|
||||
it("handles square images", { timeout: 5000 }, () => {
|
||||
expect(calculateAspectRatio(100, 100)).toEqual({ width: 1, height: 1 });
|
||||
});
|
||||
|
||||
it("handles prime dimensions", { timeout: 5000 }, () => {
|
||||
// 1279 and 719 are both prime, so ratio is irreducible
|
||||
expect(calculateAspectRatio(1279, 719)).toEqual({
|
||||
width: 1279,
|
||||
height: 719,
|
||||
});
|
||||
});
|
||||
|
||||
it("reduces 200x100 to 2:1", { timeout: 5000 }, () => {
|
||||
expect(calculateAspectRatio(200, 100)).toEqual({ width: 2, height: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── parsePngDimensions ────────────────────────────────────────────────────────
|
||||
|
||||
describe("parsePngDimensions", () => {
|
||||
it("parses 1x1 PNG", { timeout: 5000 }, () => {
|
||||
expect(parsePngDimensions(MINIMAL_PNG)).toEqual({
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses 16x32 PNG", { timeout: 5000 }, () => {
|
||||
expect(parsePngDimensions(PNG_16x32)).toEqual({
|
||||
width: 16,
|
||||
height: 32,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for buffer too short", { timeout: 5000 }, () => {
|
||||
expect(parsePngDimensions(new Uint8Array(8))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-PNG magic bytes", { timeout: 5000 }, () => {
|
||||
const bad = new Uint8Array(24);
|
||||
bad[0] = 0xff;
|
||||
expect(parsePngDimensions(bad)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when IHDR tag is missing", { timeout: 5000 }, () => {
|
||||
const bad = new Uint8Array(MINIMAL_PNG);
|
||||
bad[12] = 0x00; // corrupt IHDR tag
|
||||
expect(parsePngDimensions(bad)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for zero dimensions", { timeout: 5000 }, () => {
|
||||
const zero = new Uint8Array(MINIMAL_PNG);
|
||||
// Set width to 0
|
||||
zero[16] = 0x00;
|
||||
zero[17] = 0x00;
|
||||
zero[18] = 0x00;
|
||||
zero[19] = 0x00;
|
||||
expect(parsePngDimensions(zero)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── parseJpegDimensions ───────────────────────────────────────────────────────
|
||||
|
||||
describe("parseJpegDimensions", () => {
|
||||
it("parses baseline JPEG (SOF0) 16x16", { timeout: 5000 }, () => {
|
||||
expect(parseJpegDimensions(MINIMAL_JPEG)).toEqual({
|
||||
width: 16,
|
||||
height: 16,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses progressive JPEG (SOF2) 64x32", { timeout: 5000 }, () => {
|
||||
expect(parseJpegDimensions(PROGRESSIVE_JPEG)).toEqual({
|
||||
width: 64,
|
||||
height: 32,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for non-JPEG (no SOI)", { timeout: 5000 }, () => {
|
||||
expect(parseJpegDimensions(MINIMAL_PNG)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty buffer", { timeout: 5000 }, () => {
|
||||
expect(parseJpegDimensions(new Uint8Array([]))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no SOF marker found", { timeout: 5000 }, () => {
|
||||
// SOI only, no additional segments
|
||||
const soiOnly = new Uint8Array([0xff, 0xd8]);
|
||||
expect(parseJpegDimensions(soiOnly)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for zero dimensions in SOF", { timeout: 5000 }, () => {
|
||||
const zeroJpeg = new Uint8Array(MINIMAL_JPEG);
|
||||
// SOF0 height at offset: find SOF0 (0xff 0xc0) and corrupt height
|
||||
// SOF0 starts after APP0 segment: offset = 2 + 2 + 16 = 20 (0-indexed)
|
||||
// height is at SOF0 + 5 and + 6
|
||||
const sof0Offset = 20;
|
||||
zeroJpeg[sof0Offset + 5] = 0x00;
|
||||
zeroJpeg[sof0Offset + 6] = 0x00;
|
||||
expect(parseJpegDimensions(zeroJpeg)).toBeNull();
|
||||
});
|
||||
|
||||
it("handles FF padding bytes between JPEG markers", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Construct: SOI + (FF FF padding) + (FF C0 SOF0 with valid 8x8 dimensions)
|
||||
// The parser at offset=2 sees data[2]=0xFF, data[3]=0xFF → marker===0xFF → offset++
|
||||
// Then offset=3: data[3]=0xFF, data[4]=0xC0 → SOF0 marker, reads dimensions
|
||||
const paddedJpeg = new Uint8Array([
|
||||
0xff,
|
||||
0xd8, // SOI
|
||||
0xff,
|
||||
0xff, // FF padding (marker byte = 0xFF → skip via offset++)
|
||||
0xff,
|
||||
0xc0, // SOF0 marker
|
||||
0x00,
|
||||
0x0b, // SOF0 length = 11
|
||||
0x08, // precision
|
||||
0x00,
|
||||
0x08, // height = 8
|
||||
0x00,
|
||||
0x08, // width = 8
|
||||
0x01, // components
|
||||
]);
|
||||
const result = parseJpegDimensions(paddedJpeg);
|
||||
expect(result).toEqual({ width: 8, height: 8 });
|
||||
});
|
||||
|
||||
it("returns null when segment length is 0 causing infinite loop protection", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// JPEG with a segment that has length=0 (< 2), triggering the break
|
||||
const badJpeg = new Uint8Array([
|
||||
0xff,
|
||||
0xd8, // SOI
|
||||
0xff,
|
||||
0xe0, // APP0 marker
|
||||
0x00,
|
||||
0x00, // length = 0 (triggers segmentLength < 2 break)
|
||||
]);
|
||||
expect(parseJpegDimensions(badJpeg)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when non-0xff byte breaks marker scan", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Need at least 6 bytes so the while loop (offset+3 < data.length) executes.
|
||||
// data[2] = 0x00 (not 0xFF) → hits the `if (data[offset] !== 0xff) break` branch.
|
||||
const garbageJpeg = new Uint8Array([
|
||||
0xff,
|
||||
0xd8, // SOI
|
||||
0x00, // Not 0xFF — breaks the marker scan
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // padding so offset+3 < data.length is satisfied
|
||||
]);
|
||||
expect(parseJpegDimensions(garbageJpeg)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when SOF segment is truncated (offset+8 >= data.length)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// A JPEG where SOF0 is found but the buffer ends before offset+8,
|
||||
// triggering the `if (offset + 8 >= data.length) break` guard.
|
||||
// Layout: SOI(2) + APP0_marker(2) + APP0_len(2) + SOF0_marker_byte(at offset+1)
|
||||
// We need offset+8 >= data.length when SOF0 is encountered.
|
||||
// SOF0 at offset=2: offset+8 = 10; data.length must be <= 10.
|
||||
const truncatedSof = new Uint8Array([
|
||||
0xff,
|
||||
0xd8, // SOI at 0-1
|
||||
0xff,
|
||||
0xc0, // SOF0 marker at offset=2
|
||||
0x00,
|
||||
0x0b, // length bytes at offset+2, offset+3
|
||||
0x08, // precision at offset+4
|
||||
0x00,
|
||||
0x08, // height hi/lo at offset+5, offset+6
|
||||
// missing offset+7 (wHi) and offset+8 (wLo) → data.length=9, offset+8=10 >= 9
|
||||
]);
|
||||
expect(parseJpegDimensions(truncatedSof)).toBeNull();
|
||||
});
|
||||
});
|
||||
250
packages/media/image/compress-image/src/utils/image-utils.ts
Normal file
250
packages/media/image/compress-image/src/utils/image-utils.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import {
|
||||
COMPRESS_IMAGE_CODES,
|
||||
CompressImageError,
|
||||
} from "../errors/compress-image-error.js";
|
||||
import type { ImageFormat } from "../types/compress-image.types.js";
|
||||
|
||||
/**
|
||||
* Maximum permitted image dimension in pixels (matches JPEG/PNG spec limits).
|
||||
*/
|
||||
const MAX_DIMENSION = 32767;
|
||||
|
||||
/**
|
||||
* Detect the image format from magic bytes at the start of the buffer.
|
||||
*
|
||||
* Supports JPEG (FFD8FF), PNG (89504E47), WebP (RIFF....WEBP), and AVIF
|
||||
* (ftyp box with 'avif' or 'avis' brand). Returns `null` when no signature
|
||||
* matches — the caller is responsible for surfacing an appropriate error.
|
||||
*
|
||||
* @param data - Raw image bytes.
|
||||
* @returns The detected `ImageFormat`, or `null` if unrecognized.
|
||||
*/
|
||||
export function detectFormat(data: Uint8Array): ImageFormat | null {
|
||||
if (data.length < 4) return null;
|
||||
|
||||
// JPEG: starts with FF D8 FF
|
||||
if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
|
||||
return "jpeg";
|
||||
}
|
||||
|
||||
// PNG: starts with 89 50 4E 47 (‰PNG)
|
||||
if (
|
||||
data[0] === 0x89 &&
|
||||
data[1] === 0x50 &&
|
||||
data[2] === 0x4e &&
|
||||
data[3] === 0x47
|
||||
) {
|
||||
return "png";
|
||||
}
|
||||
|
||||
// WebP: RIFF....WEBP — needs at least 12 bytes
|
||||
if (data.length >= 12) {
|
||||
const isRiff =
|
||||
data[0] === 0x52 && // R
|
||||
data[1] === 0x49 && // I
|
||||
data[2] === 0x46 && // F
|
||||
data[3] === 0x46; // F
|
||||
const isWebp =
|
||||
data[8] === 0x57 && // W
|
||||
data[9] === 0x45 && // E
|
||||
data[10] === 0x42 && // B
|
||||
data[11] === 0x50; // P
|
||||
if (isRiff && isWebp) return "webp";
|
||||
}
|
||||
|
||||
// AVIF: ISO Base Media File Format with 'avif' or 'avis' ftyp brand
|
||||
// ftyp box starts at offset 0: [size(4)][ftyp(4)][major brand(4)]
|
||||
if (data.length >= 12) {
|
||||
const box =
|
||||
data[4] === 0x66 && // f
|
||||
data[5] === 0x74 && // t
|
||||
data[6] === 0x79 && // y
|
||||
data[7] === 0x70; // p
|
||||
if (box) {
|
||||
// data.length >= 12 is guaranteed above, so indices 8-11 are always defined.
|
||||
const b0 = data[8] as number;
|
||||
const b1 = data[9] as number;
|
||||
const b2 = data[10] as number;
|
||||
const b3 = data[11] as number;
|
||||
const brand = String.fromCharCode(b0, b1, b2, b3);
|
||||
if (brand === "avif" || brand === "avis") return "avif";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate width and height values for use in a compression or resize operation.
|
||||
*
|
||||
* Throws `CompressImageError` with code `INVALID_OPTIONS` if either dimension
|
||||
* is non-positive or exceeds the maximum supported value of 32767.
|
||||
*
|
||||
* @param width - Optional target width in pixels.
|
||||
* @param height - Optional target height in pixels.
|
||||
* @throws {CompressImageError} When any provided dimension is out of range.
|
||||
*/
|
||||
export function validateDimensions(width?: number, height?: number): void {
|
||||
if (width !== undefined) {
|
||||
if (!Number.isInteger(width) || width <= 0 || width > MAX_DIMENSION) {
|
||||
throw new CompressImageError(
|
||||
COMPRESS_IMAGE_CODES["INVALID_OPTIONS"],
|
||||
`Invalid width: ${width}. Must be an integer between 1 and ${MAX_DIMENSION}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (height !== undefined) {
|
||||
if (
|
||||
!Number.isInteger(height) ||
|
||||
height <= 0 ||
|
||||
height > MAX_DIMENSION
|
||||
) {
|
||||
throw new CompressImageError(
|
||||
COMPRESS_IMAGE_CODES["INVALID_OPTIONS"],
|
||||
`Invalid height: ${height}. Must be an integer between 1 and ${MAX_DIMENSION}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the greatest common divisor of two positive integers using the
|
||||
* Euclidean algorithm. Used internally by `calculateAspectRatio`.
|
||||
*/
|
||||
function gcd(a: number, b: number): number {
|
||||
let x = a;
|
||||
let y = b;
|
||||
while (y !== 0) {
|
||||
const t = y;
|
||||
y = x % y;
|
||||
x = t;
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the reduced aspect ratio for the given image dimensions.
|
||||
*
|
||||
* For example, a 1920×1080 image returns `{ width: 16, height: 9 }`.
|
||||
*
|
||||
* @param width - Image width in pixels (must be positive).
|
||||
* @param height - Image height in pixels (must be positive).
|
||||
* @returns The reduced `{ width, height }` ratio.
|
||||
*/
|
||||
export function calculateAspectRatio(
|
||||
width: number,
|
||||
height: number
|
||||
): { width: number; height: number } {
|
||||
const divisor = gcd(width, height);
|
||||
return { width: width / divisor, height: height / divisor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the pixel dimensions from a PNG buffer by reading the IHDR chunk.
|
||||
*
|
||||
* The PNG specification places the IHDR chunk immediately after the 8-byte
|
||||
* file signature. Width occupies bytes 16–19 and height bytes 20–23 (big-endian
|
||||
* unsigned 32-bit integers).
|
||||
*
|
||||
* @param data - Raw PNG bytes.
|
||||
* @returns `{ width, height }` if parsing succeeded, `null` otherwise.
|
||||
*/
|
||||
export function parsePngDimensions(
|
||||
data: Uint8Array
|
||||
): { width: number; height: number } | null {
|
||||
// Need at least 24 bytes: 8 magic + 4 length + 4 "IHDR" + 4 width + 4 height
|
||||
if (data.length < 24) return null;
|
||||
|
||||
// Verify PNG magic
|
||||
if (
|
||||
data[0] !== 0x89 ||
|
||||
data[1] !== 0x50 ||
|
||||
data[2] !== 0x4e ||
|
||||
data[3] !== 0x47
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// IHDR chunk type at bytes 12–15
|
||||
if (
|
||||
data[12] !== 0x49 || // I
|
||||
data[13] !== 0x48 || // H
|
||||
data[14] !== 0x44 || // D
|
||||
data[15] !== 0x52 // R
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Width at bytes 16–19, Height at bytes 20–23 (big-endian uint32).
|
||||
// data.length >= 24 is guaranteed by the guard above, so these are always defined.
|
||||
const w0 = data[16] as number;
|
||||
const w1 = data[17] as number;
|
||||
const w2 = data[18] as number;
|
||||
const w3 = data[19] as number;
|
||||
const h0 = data[20] as number;
|
||||
const h1 = data[21] as number;
|
||||
const h2 = data[22] as number;
|
||||
const h3 = data[23] as number;
|
||||
const width = ((w0 << 24) | (w1 << 16) | (w2 << 8) | w3) >>> 0;
|
||||
const height = ((h0 << 24) | (h1 << 16) | (h2 << 8) | h3) >>> 0;
|
||||
|
||||
if (width === 0 || height === 0) return null;
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse pixel dimensions from a JPEG buffer by scanning for SOF markers.
|
||||
*
|
||||
* JPEG dimensions are stored in Start Of Frame (SOF) segments, specifically
|
||||
* SOF0 (0xFF 0xC0) for baseline and SOF2 (0xFF 0xC2) for progressive JPEGs.
|
||||
* The frame header layout is: [marker(2)][length(2)][precision(1)][height(2)][width(2)].
|
||||
*
|
||||
* @param data - Raw JPEG bytes.
|
||||
* @returns `{ width, height }` if a SOF segment was found, `null` otherwise.
|
||||
*/
|
||||
export function parseJpegDimensions(
|
||||
data: Uint8Array
|
||||
): { width: number; height: number } | null {
|
||||
// Must start with SOI marker FF D8
|
||||
if (data.length < 2 || data[0] !== 0xff || data[1] !== 0xd8) return null;
|
||||
|
||||
let offset = 2;
|
||||
while (offset + 3 < data.length) {
|
||||
// Each marker starts with 0xFF
|
||||
if (data[offset] !== 0xff) break;
|
||||
|
||||
const marker = data[offset + 1];
|
||||
// Skip padding 0xFF bytes
|
||||
if (marker === 0xff) {
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// The while-condition guarantees offset+3 < data.length, so these are always defined.
|
||||
const lenHi = data[offset + 2] as number;
|
||||
const lenLo = data[offset + 3] as number;
|
||||
// Segment length is big-endian uint16 at offset+2 (includes length bytes)
|
||||
const segmentLength = ((lenHi << 8) | lenLo) >>> 0;
|
||||
|
||||
// SOF0 = 0xC0 (baseline), SOF2 = 0xC2 (progressive)
|
||||
if (marker === 0xc0 || marker === 0xc2) {
|
||||
// SOF layout: [marker(2)][length(2)][precision(1)][height(2)][width(2)]
|
||||
if (offset + 8 >= data.length) break;
|
||||
// offset+8 < data.length is guaranteed by the guard above.
|
||||
const hHi = data[offset + 5] as number;
|
||||
const hLo = data[offset + 6] as number;
|
||||
const wHi = data[offset + 7] as number;
|
||||
const wLo = data[offset + 8] as number;
|
||||
const height = ((hHi << 8) | hLo) >>> 0;
|
||||
const width = ((wHi << 8) | wLo) >>> 0;
|
||||
if (width === 0 || height === 0) return null;
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
// Skip to the next marker
|
||||
if (segmentLength < 2) break;
|
||||
offset += 2 + segmentLength;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
7
packages/media/image/compress-image/src/utils/index.ts
Normal file
7
packages/media/image/compress-image/src/utils/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
calculateAspectRatio,
|
||||
detectFormat,
|
||||
parseJpegDimensions,
|
||||
parsePngDimensions,
|
||||
validateDimensions,
|
||||
} from "./image-utils";
|
||||
40
packages/media/image/compress-image/tests/dep-graph.test.ts
Normal file
40
packages/media/image/compress-image/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("@suaveplan/* runtime deps are exactly error + types", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
};
|
||||
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
|
||||
.filter((name) => name.startsWith("@suaveplan/"))
|
||||
.sort();
|
||||
expect(runtimeScoped).toEqual(["@suaveplan/error", "@suaveplan/types"]);
|
||||
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
|
||||
(name) => name.startsWith("@suaveplan/")
|
||||
);
|
||||
expect(peerScoped).toEqual([]);
|
||||
});
|
||||
|
||||
it("runtime deps are exactly the expected set (sorted)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { dependencies?: Record<string, string> };
|
||||
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
|
||||
expect(runtime).toEqual([
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC = join(__dirname, "..", "src");
|
||||
|
||||
const BANNED: ReadonlyArray<RegExp> = [
|
||||
/\bnode:fs\b/,
|
||||
/\bnode:path\b/,
|
||||
/\bnode:url\b/,
|
||||
/\bprocess\./,
|
||||
/\bBuffer\b/,
|
||||
/\bdocument\b/,
|
||||
/\bwindow\b/,
|
||||
];
|
||||
|
||||
function* walk(dir: string): Generator<string> {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) yield* walk(full);
|
||||
else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
describe("no-disallowed", () => {
|
||||
it("src/ does not reference Node or browser built-ins", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const hits: string[] = [];
|
||||
for (const file of walk(SRC)) {
|
||||
const content = readFileSync(file, "utf8");
|
||||
for (const pattern of BANNED) {
|
||||
if (pattern.test(content)) {
|
||||
hits.push(`${file} matched ${pattern}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
20
packages/media/image/compress-image/tests/tree-shake.test.ts
Normal file
20
packages/media/image/compress-image/tests/tree-shake.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("tree-shake", () => {
|
||||
it("sideEffects is false", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { sideEffects?: boolean };
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("exports has at least 5 entries", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { exports?: Record<string, unknown> };
|
||||
expect(pkg.exports).toBeDefined();
|
||||
expect(Object.keys(pkg.exports ?? {}).length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
12
packages/media/image/compress-image/tsconfig.lib.json
Normal file
12
packages/media/image/compress-image/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
27
packages/media/image/compress-image/vite.config.ts
Normal file
27
packages/media/image/compress-image/vite.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/compress-image",
|
||||
plugins: [dts({ entryRoot: "src", tsconfigPath: "./tsconfig.lib.json", insertTypesEntry: true })],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: { preserveModules: true, preserveModulesRoot: "src", entryFileNames: "[name].js" },
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
25
packages/media/image/compress-image/vitest.config.ts
Normal file
25
packages/media/image/compress-image/vitest.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user