hazo_images
v1.7.0
Published
Image processing pipeline for the hazo ecosystem — Sharp wrapper, thumbnail generation, EXIF handling, and hazo_files integration helper
Readme
hazo_images
Image processing pipeline for the hazo ecosystem. Sharp wrapper with EXIF handling, auto-rotate, resize, thumbnail generation, and a full suite of geometry / filter / output controls. Composes with hazo_files via an integration helper. Ships a framework-agnostic route-handler factory and, since @1.4.0, a real React editor dialog in hazo_images/ui.
Image storage is owned by hazo_files. This package handles processing only.
Install
npm install hazo_images
# Required peer dep — provides errors, correlation IDs, logger, config loader
npm install hazo_core
# Required peer deps for image processing
npm install sharp
# Optional — only needed if using uploadProcessedImage
npm install hazo_files
# Optional — only needed if using hazo_images/ui components
npm install hazo_ui react react-domUsage
processImage — standalone pipeline primitive
import { processImage } from 'hazo_images/server';
const fs = await import('node:fs/promises');
const buffer = await fs.readFile('./photo.jpg');
const result = await processImage(buffer, {
stripExif: true, // default: true — privacy-safe
autoRotate: true, // default: true — corrects EXIF orientation
maxDimension: 4096, // default: 4096 — resize longest side, keep AR
webp: false, // default: false — preserve original format
thumbnails: [256, 512, 1024],
});
console.log(result.metadata);
// { width: 2400, height: 1600, format: 'jpeg' }
console.log(result.thumbnails.length);
// 3 — one per requested size
// result.buffer — processed main image as Buffer
// result.thumbnails[0] — { size: 256, buffer: Buffer, format: 'jpeg' }uploadProcessedImage — compose with hazo_files
import { uploadProcessedImage } from 'hazo_images/server';
import { createInitializedFileManager } from 'hazo_files/server';
const fm = await createInitializedFileManager({ config: { provider: 'local', local: { basePath: './uploads' } } });
const buffer = await fs.readFile('./photo.jpg');
const result = await uploadProcessedImage(fm, buffer, '/photos/wedding.jpg', {
thumbnails: [256, 512, 1024],
stripExif: true,
autoRotate: true,
maxDimension: 4096,
webp: false,
});
console.log(result.main); // FileItem — the processed main image
console.log(result.thumbs); // { 256: FileItem, 512: FileItem, 1024: FileItem }Thumbnails are named {basename}__thumb_{size}.{ext} and stored in the same directory as the main file:
/photos/wedding.jpg
/photos/wedding__thumb_256.jpg
/photos/wedding__thumb_512.jpg
/photos/wedding__thumb_1024.jpgWebP transcoding
const result = await processImage(buffer, {
webp: true,
thumbnails: [256, 512],
});
// result.metadata.format === 'webp'
// result.thumbnails[0].format === 'webp'
// thumbnail extension in uploadProcessedImage: __thumb_256.webpError Types
All errors extend HazoError subclasses from hazo_core (Wave 2). The
legacy class names are preserved so existing instanceof checks and
errorType discriminators keep working.
import {
ImageProcessingError,
ImageUploadError,
SharpMissingError,
UnsupportedFormatError,
} from 'hazo_images';
import { HazoError } from 'hazo_core';
try {
const result = await processImage(badBuffer);
} catch (err) {
if (err instanceof UnsupportedFormatError) {
// Buffer was not a recognized image format (e.g. PDF, text)
console.error('Not an image:', err.message);
} else if (err instanceof ImageProcessingError) {
// Sharp failed during processing
console.error('Processing failed:', err.message);
console.error('Original error:', err.originalError);
} else if (HazoError.is(err)) {
// Any other hazo_images error — read `err.code` to discriminate
console.error(err.code, err.message);
}
}Error reference
| Class | Extends | Code | HTTP | When |
|---|---|---|---|---|
| UnsupportedFormatError | HazoValidationError | HAZO_IMAGES_UNSUPPORTED_FORMAT | 400 | Buffer is not a recognized image format |
| ImageProcessingError | HazoExternalError | HAZO_IMAGES_PROCESSING_FAILED | 502 | Sharp failed during processing or thumbnail generation |
| SharpMissingError | HazoConfigError | HAZO_IMAGES_CONFIG_SHARP_MISSING | n/a | Optional sharp peer dep is not installed |
| ImageUploadError | HazoExternalError | HAZO_IMAGES_UPLOAD_FAILED | 502 | Underlying FileManager.uploadFile returned success: false |
Subpath exports
| Import | Contents |
|---|---|
| hazo_images | Shared types + error classes (isomorphic, no Node deps) |
| hazo_images/server | processImage, uploadProcessedImage, createImageProcessHandler (Node.js only) |
| hazo_images/ui | ImageUploader, ImageViewer, ImageEditorDialog + control-schema helpers (React, client-only) |
| hazo_images/runware | createRunwareClient, assemblePrompts, 4 error classes (Node.js only) |
Supported formats
Passes through whatever Sharp supports: JPEG, PNG, WebP, AVIF, TIFF, GIF (static), SVG (rasterized). HEIC requires the optional sharp-heic package installed separately.
Options reference
interface ProcessImageOptions {
stripExif?: boolean; // default: true — strip all EXIF metadata
autoRotate?: boolean; // default: true — apply EXIF orientation, then strip tag
maxDimension?: number; // default: 4096 — longest side; 0 = disable resize
webp?: boolean; // default: false — transcode to WebP
thumbnails?: number[]; // default: [] — pixel sizes for longest side
// Geometry (Phase 1)
crop?: { // Extract region — applied BEFORE rotate, clamped to (auto-oriented) input dims
left: number; top: number; width: number; height: number;
shape?: 'rect' | 'square' | 'circle'; // 'circle' masks corners transparent & forces PNG output
};
rotate?: number; // Rotate by degrees (Sharp fills gaps with black)
flip?: boolean; // Flip vertically (top↔bottom)
flop?: boolean; // Flop horizontally (left↔right)
// Filters (Phase 1)
grayscale?: boolean; // Convert to greyscale
sharpen?: number; // Unsharp-mask sigma (e.g. 1.5)
blur?: number; // Gaussian blur sigma (e.g. 1.0)
brightness?: number; // Modulate multiplier — 1 = unchanged (e.g. 1.2 = +20%)
saturation?: number; // Modulate multiplier — 1 = unchanged (e.g. 0 = greyscale)
hue?: number; // Hue rotation in degrees (e.g. 180 = complementary)
tint?: string; // Hex colour tint applied via Sharp's tint() (e.g. "#ff0000")
// Output (Phase 1)
quality?: number; // JPEG / WebP quality 1–100 (default: 80)
}Pipeline order
The Sharp pipeline applies operations in this order:
autoOrient— apply EXIF orientationextract—cropregion (clamped to the auto-oriented input dims). Forshape: 'circle'a circular alpha mask is composited (corners → transparent) and output is forced to PNG. The crop is baked to an intermediate buffer so the following rotate is applied after the crop (Sharp otherwise reordersrotatebefore a pre-resizeextract).rotate— explicit rotation (degrees)flip/flop— vertical / horizontal mirrorwithMetadata(false)— strip EXIF (whenstripExif: true)resize— longest side tomaxDimension(maintaining aspect ratio)modulate—brightness,saturation,huegrayscalesharpen/blurtint- Format encode —
webp/jpeg/pngwithquality(PNG forced for circular crops) - Thumbnail generation (each thumbnail runs the same pipeline with a different
resizetarget)
Route handler factory (createImageProcessHandler)
createImageProcessHandler returns a framework-agnostic (request: Request) => Promise<Response> suitable for mounting directly as a Next.js App-Router route handler (or any other WHATWG Request/Response environment).
// app/api/images/process/route.ts
import { createImageProcessHandler } from 'hazo_images/server';
import { createInitializedFileManager } from 'hazo_files/server';
export const POST = createImageProcessHandler({
getFileManager: async () =>
createInitializedFileManager({ config: { provider: 'local', local: { basePath: './uploads' } } }),
maxUploadBytes: 15 * 1024 * 1024, // default: 15 MB
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
previewMaxDimension: 1600, // default: 1600 — size of preview returned in response
savePathPrefix: '/hazo-images', // default: '/hazo-images'
});Request format (multipart FormData)
| Field | Type | Required | Description |
|---|---|---|---|
| image | File | yes | The source image file |
| options | JSON string | no | ProcessImageOptions to apply |
| save | "true" | no | Persist via the file manager |
| filename | string | no | Override the stored filename (otherwise uses uploaded filename) |
Response JSON
Success:
{
"ok": true,
"metadata": { "width": 1200, "height": 800, "format": "jpeg" },
"preview": "data:image/jpeg;base64,…",
"originalPreview": "data:image/jpeg;base64,…",
"thumbnails": [{ "size": 256, "format": "jpeg", "dataUrl": "data:image/jpeg;base64,…" }],
"saved": { "path": "/hazo-images/photo.jpg", "url": "https://…" }
}saved is null when save is not "true". saveError (string) is present if the file manager threw but the preview still succeeded.
Error:
{ "ok": false, "error": "Unsupported image format", "errorType": "UnsupportedFormatError" }HTTP status: UnsupportedFormatError → 400, ImageProcessingError → 422, other → 500.
Types
import type {
CreateImageProcessHandlerOptions,
ImageProcessHandlerFileManager,
} from 'hazo_images/server';ImageProcessHandlerFileManager is a duck-typed interface (uploadFile method) — any hazo_files FileManager satisfies it.
<ImageEditorDialog> (hazo_images/ui)
A full-featured client-side image editor dialog built on hazo_ui (shadcn/ui primitives). Requires hazo_ui ^4.4.0, react, and react-dom (all optional peers — install them only if you use this subpath).
"use client"required. All exports fromhazo_images/uiare client components. Import them inside"use client"files or dynamic-import withssr: false.
Minimal usage (uncontrolled, default endpoint)
'use client';
import { ImageEditorDialog } from 'hazo_images/ui';
export function MyPage() {
return (
<ImageEditorDialog
imageUrl="/uploads/photo.jpg"
endpoint="/api/images/process"
onSave={(result) => console.log('saved', result.saved?.path)}
/>
);
}The dialog talks to the endpoint URL with the multipart format expected by createImageProcessHandler. A live before/after preview updates after each debounced control change.
Customised controls
import { ImageEditorDialog, DEFAULT_IMAGE_EDIT_CONTROLS } from 'hazo_images/ui';
<ImageEditorDialog
imageUrl="/uploads/photo.jpg"
endpoint="/api/images/process"
controls={[
...DEFAULT_IMAGE_EDIT_CONTROLS,
{ key: 'tint', label: 'Tint', type: 'color', tab: 'filters', default: '#ffffff' },
]}
defaultOptions={{ quality: 90, maxDimension: 2000 }}
debounceMs={400}
labels={{ save: 'Export', cancel: 'Discard' }}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| imageUrl | string | — | Source image URL (displayed as "before") |
| endpoint | string | — | URL of createImageProcessHandler route (use this or onProcess) |
| onProcess | (opts: ProcessImageOptions) => Promise<...> | — | Custom processing callback instead of endpoint |
| onSave | (result) => void | — | Called after a successful save |
| controls | ImageEditControl[] | DEFAULT_IMAGE_EDIT_CONTROLS | Declarative control schema (see below) |
| defaultOptions | ProcessImageOptions | {} | Options pre-applied on open |
| debounceMs | number | 300 | Debounce delay (ms) before sending preview request |
| labels | Partial<EditorLabels> | — | Override button / tab label strings |
| headerSlot | React.ReactNode | — | Extra content in the dialog header |
| footerSlot | React.ReactNode | — | Extra content in the dialog footer |
| className | string | — | Extra class on the dialog container |
The dialog is organised into three tabs: Geometry (crop / rotate / flip / flop), Filters (grayscale / sharpen / blur / brightness / saturation / hue / tint), and Output (format / quality / resize).
The stage shows a live preview. Geometry (rotate / flip / flop) and most filters (blur / brightness / saturation / hue / grayscale / tint) render instantly as a CSS transform / filter / blend on the stage image — they update in realtime while you drag, with no server round-trip. These are omitted from the debounced (debounceMs) server preview to avoid double-applying; Save bakes them server-side via Sharp so output is exact. sharpen, crop, and output (format / quality / resize) are reflected via the debounced server preview.
The rotate control shows 0° / 90° / 180° / 270° preset buttons beside the slider for one-click quarter turns. The tint control is a native color picker (swatch + editable hex + Clear).
Crop is a toggle mode on the Geometry tab: click Crop image to swap the stage to the original with a draggable box (8 resize handles) and a shape picker — rectangle, square (1:1), or circle (1:1, masked to a transparent-cornered PNG) — then Done to return to the live preview. A Reset button (outlined) restores every control to its default and clears the crop, keeping the loaded image. Override the Reset, Crop image, and Done strings via labels.reset / labels.crop / labels.cropDone.
Helper exports
import {
DEFAULT_IMAGE_EDIT_CONTROLS, // ImageEditControl[] — the default control schema
defaultControlValues, // Record<string, unknown> — zeroed defaults
controlValuesToOptions, // (values) => ProcessImageOptions
validate_image_edit_controls // (controls) => string[] — validation errors
} from 'hazo_images/ui';Control schema types
import type { ImageEditControl } from 'hazo_images/ui';
// Each control: { key, label, type, tab, default, min?, max?, step? }
// type: 'slider' | 'switch' | 'select' | 'color'
// tab: 'geometry' | 'filters' | 'output'Runware (AI image generation)
hazo_images/runware wraps the Runware REST API behind a factory client.
import { createRunwareClient } from 'hazo_images/runware';
const client = createRunwareClient({
// apiKey?: defaults to HAZO_IMAGES_RUNWARE_API_KEY then RUNWARE_API_KEY
defaultModel: 'runware:z-image@turbo',
timeoutMs: 60_000,
});
const { imageURL, cost, taskUUID } = await client.generateImage({
positivePrompt: 'a chibi explorer with a stopwatch',
width: 1024,
height: 1024,
});Error classes
| Class | Triggered by | Code(s) |
|---|---|---|
| RunwareApiError | network fail, 5xx, abort, empty result | HAZO_IMAGES_RUNWARE_HTTP_FAILURE, HAZO_IMAGES_RUNWARE_TIMEOUT, HAZO_IMAGES_RUNWARE_EMPTY_RESULT |
| RunwareAuthError | missing key, 401, 403 | HAZO_IMAGES_RUNWARE_MISSING_KEY, HAZO_IMAGES_RUNWARE_AUTH_FAILED |
| RunwareRateLimitError | 429 — exposes retryAfter: number \| null | HAZO_IMAGES_RUNWARE_RATE_LIMITED |
| RunwareValidationError | 400 or errors[] in body | upstream code or HAZO_IMAGES_RUNWARE_BAD_REQUEST |
assemblePrompts
Generic helper for the prefix + character + image + suffix pattern:
import { assemblePrompts } from 'hazo_images/runware';
const { positive, negative } = assemblePrompts({
imagePrompt: 'astronaut on mars',
masterStylePrefix: 'cinematic,',
characterPositive: 'red spacesuit',
masterNegative: 'low quality, blurry',
});Roadmap
| Version | Scope |
|---|---|
| @1.0 | Server pipeline: processImage + uploadProcessedImage |
| @1.1 | Wave 2 standardisation: HazoError subclasses, structured logging, INI config |
| @1.2 | hazo_images/runware sub-export (AI image generation) |
| @1.3 | blur Gaussian-blur option on processImage |
| @1.4 | Extended pipeline options (crop / rotate / flip / flop / grayscale / sharpen / brightness / saturation / hue / tint / quality); createImageProcessHandler factory; hazo_images/ui (ImageEditorDialog + helpers) |
| @1.5 | ImageEditorDialog: instant CSS live preview for geometry + filters (realtime while dragging); interactive crop (drag + 8 handles) with rect / square / circle shapes; rotate 0/90/180/270 presets; tint color picker; Reset button. Server: crop runs before rotate; crop.shape: 'circle' masks to a transparent PNG |
License
MIT
