hazo_images 1.2.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGE_LOG.md CHANGED
@@ -1,5 +1,70 @@
1
1
  # hazo_images Change Log
2
2
 
3
+ ## 1.7.0 — 2026-06-22
4
+ ### Added
5
+ - `deriveVariant(sourceFileId, deps, opts)` — fileId-in/fileId-out convenience over `processImage` + `uploadProcessedImage`
6
+ - New exports: `DeriveVariantOptions`, `DeriveVariantDeps`, `DeriveVariantResult`
7
+ - Autotest scenario + `/derive-variant` test-app page
8
+
9
+ ---
10
+
11
+ ## 1.5.0 — 2026-06-21
12
+
13
+ ### Added
14
+
15
+ - **Instant live preview for geometry + filters.** The `ImageEditorDialog` stage now renders **rotate**, **flip**, **flop**, **blur**, **brightness**, **saturation**, **hue**, **grayscale**, and **tint** as a CSS transform/filter/blend on the stage image, so they update truly in realtime while dragging (no server round-trip lag). These controls are omitted from the debounced server preview to avoid double-applying; **Save** still bakes them server-side via Sharp, so output is exact. `sharpen`, crop, and output (webp / quality / max-dimension) remain server-rendered. (Earlier in 1.5.0 every control went through the debounced server preview, which made sliders feel like they only updated on release.)
16
+ - **Tint preview** uses a `mix-blend-mode: color` overlay (preserves the image's luminance while applying the tint's hue/chroma — approximates Sharp's `tint()`).
17
+ - **Interactive crop with shapes.** Crop is now a toggle mode (**Crop image** / **Done**) with a draggable box and 8 resize handles (4 corners + 4 edges), replacing the draw-only `CropOverlay`. A shape picker offers **rectangle**, **square** (constrained 1:1), and **circle** (constrained 1:1; corners masked transparent → PNG output). The box renders a circular outline for the circle shape.
18
+ - **Rotate quarter-turn presets.** The rotate control shows **0° / 90° / 180° / 270°** buttons beside the slider (active preset highlighted) for one-click quarter turns.
19
+ - **Tint color picker.** New `'color'` control `input_type` (added to the control schema + validator). The `tint` control renders a native color swatch + editable hex field + **Clear** button instead of a plain text box.
20
+ - **Reset button.** Restores every control to its default and clears the crop region / shape, keeping the loaded image; now rendered with an outlined button border. New `labels.reset` / `labels.crop` / `labels.cropDone` overrides.
21
+ - **`crop.shape`** option on `ProcessImageOptions` (`'rect' | 'square' | 'circle'`, default `'rect'`). `'circle'` composites a circular alpha mask and forces PNG output (overrides `webp` / `jpeg`).
22
+
23
+ ### Changed
24
+
25
+ - **Crop now runs before rotate** in the server pipeline, clamped against the **auto-oriented** input dimensions. The crop is baked to a lossless intermediate buffer first, because Sharp otherwise applies `rotate` before a pre-resize `extract` regardless of call order — which cropped the wrong region for rotate+crop combinations. The crop is the region the user drew on the (auto-oriented) original; rotate/flip apply to the result.
26
+ - **`ImageEditorDialog` — "Studio" side-rail layout.** The dialog body was a single vertical stack with the image preview rendered last (below all controls). It is now a two-pane layout: a large image **stage** on the left (`bg-gray-900`, `min-h` 52–64vh, `object-contain`) and a compact scrollable **controls rail** on the right (`lg:w-80`) holding the Geometry / Filters / Output tabs.
27
+ - Stage shows the live processed **Preview** when available, else the **Original** (badge top-left); a processing badge appears top-right while a render is in flight.
28
+ - Control rows are stacked (label above input) to read well in the narrow rail; slider/switch/select behaviour is unchanged.
29
+ - Dialog widened `max-w-3xl` → `max-w-5xl`; panes stack vertically below `lg`. The standalone `ImageViewer` (before/after grid) is no longer used inside the dialog but remains a separate `hazo_images/ui` export.
30
+ - Demo: `test-app /editor` exercises the new layout (no API change).
31
+
32
+ ---
33
+
34
+ ## 1.4.0 — 2026-06-20
35
+
36
+ ### Added
37
+
38
+ - **Extended pipeline options** on `processImage` / `ProcessImageOptions` (all optional, default no-op):
39
+ - Geometry: `crop` (extract region, clamped to input dims), `rotate` (degrees), `flip` (vertical), `flop` (horizontal).
40
+ - Filters: `grayscale`, `sharpen` (sigma), `brightness` (modulate multiplier), `saturation` (modulate multiplier), `hue` (degrees), `tint` (hex).
41
+ - Output: `quality` (1–100, applied to WebP + JPEG output).
42
+ - Pipeline order documented: autoOrient → rotate → flip/flop → withMetadata? → crop → resize → modulate → grayscale → sharpen/blur → tint → format+quality → thumbnails.
43
+ - **`createImageProcessHandler(opts?)`** — framework-agnostic `(request: Request) => Promise<Response>` factory exported from `hazo_images/server`. Parses multipart `FormData` (`image` File + `options` JSON + `save` flag + optional `filename`). Returns JSON `{ ok, metadata, preview, originalPreview, thumbnails, saved, saveError? }` or `{ ok: false, error, errorType }` (UnsupportedFormatError → 400, ImageProcessingError → 422, other → 500). Exports companion types `CreateImageProcessHandlerOptions` and `ImageProcessHandlerFileManager`.
44
+ - **`hazo_images/ui`** — real React components (was an empty reserved stub):
45
+ - `ImageUploader` — drag-and-drop / file-picker input component.
46
+ - `ImageViewer` — before/after preview panel.
47
+ - `ImageEditorDialog` — headline component: `hazo_ui` Dialog + Geometry / Filters / Output tabs of Slider / Switch / Select / color controls, debounced live preview, Save action (persists via the Phase-2 handler). Customisation: `controls` declarative schema, `defaultOptions`, `endpoint` | `onProcess`, `onSave`, `labels`, `headerSlot` / `footerSlot`, `className`, `debounceMs`.
48
+ - Helper exports: `DEFAULT_IMAGE_EDIT_CONTROLS`, `defaultControlValues`, `controlValuesToOptions`, `validate_image_edit_controls`.
49
+ - Control-schema types: `ImageEditControl` and related interfaces (source in `src/ui/controls-schema.ts`).
50
+ - **`hazo_ui ^4.4.0`** added as an optional peer dependency (used only by `hazo_images/ui`; server pipeline + handler never import `hazo_ui`).
51
+
52
+ ### Notes
53
+
54
+ - `hazo_images/ui` is now a built sub-export with real components. The control-schema types were **copied** (not moved) from the build-excluded `src/components/` into `src/ui/controls-schema.ts`.
55
+ - `src/components/` remains build-excluded and MUST stay in the repo — see "Deferred Components" in `CLAUDE.md`.
56
+ - `createImageProcessHandler` returns a plain `Response` with no hazo_api envelopes — consistent with D-2.1.0-01.
57
+
58
+ ---
59
+
60
+ ## 1.3.0 — 2026-06-20
61
+
62
+ ### Added
63
+
64
+ - `blur?: number` option on `processImage` — Gaussian blur sigma (applied after `sharpen` in the pipeline, before `tint`).
65
+
66
+ ---
67
+
3
68
  ## 1.2.0 — 2026-05-30
4
69
 
5
70
  ### Added
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # hazo_images
2
2
 
3
- Server-side image processing pipeline for the hazo ecosystem. Sharp wrapper with EXIF handling, auto-rotate, resize, and thumbnail generation. Composes with `hazo_files` via an integration helper.
3
+ 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`.
4
4
 
5
5
  Image **storage** is owned by `hazo_files`. This package handles **processing only**.
6
6
 
@@ -17,6 +17,9 @@ npm install sharp
17
17
 
18
18
  # Optional — only needed if using uploadProcessedImage
19
19
  npm install hazo_files
20
+
21
+ # Optional — only needed if using hazo_images/ui components
22
+ npm install hazo_ui react react-dom
20
23
  ```
21
24
 
22
25
  ## Usage
@@ -134,8 +137,9 @@ try {
134
137
  | Import | Contents |
135
138
  |---|---|
136
139
  | `hazo_images` | Shared types + error classes (isomorphic, no Node deps) |
137
- | `hazo_images/server` | `processImage`, `uploadProcessedImage` (Node.js only) |
138
- | `hazo_images/ui` | Reserved for `@1.1+` UI components (empty in `@1.0`) |
140
+ | `hazo_images/server` | `processImage`, `uploadProcessedImage`, `createImageProcessHandler` (Node.js only) |
141
+ | `hazo_images/ui` | `ImageUploader`, `ImageViewer`, `ImageEditorDialog` + control-schema helpers (React, client-only) |
142
+ | `hazo_images/runware` | `createRunwareClient`, `assemblePrompts`, 4 error classes (Node.js only) |
139
143
 
140
144
  ## Supported formats
141
145
 
@@ -150,9 +154,198 @@ interface ProcessImageOptions {
150
154
  maxDimension?: number; // default: 4096 — longest side; 0 = disable resize
151
155
  webp?: boolean; // default: false — transcode to WebP
152
156
  thumbnails?: number[]; // default: [] — pixel sizes for longest side
157
+
158
+ // Geometry (Phase 1)
159
+ crop?: { // Extract region — applied BEFORE rotate, clamped to (auto-oriented) input dims
160
+ left: number; top: number; width: number; height: number;
161
+ shape?: 'rect' | 'square' | 'circle'; // 'circle' masks corners transparent & forces PNG output
162
+ };
163
+ rotate?: number; // Rotate by degrees (Sharp fills gaps with black)
164
+ flip?: boolean; // Flip vertically (top↔bottom)
165
+ flop?: boolean; // Flop horizontally (left↔right)
166
+
167
+ // Filters (Phase 1)
168
+ grayscale?: boolean; // Convert to greyscale
169
+ sharpen?: number; // Unsharp-mask sigma (e.g. 1.5)
170
+ blur?: number; // Gaussian blur sigma (e.g. 1.0)
171
+ brightness?: number; // Modulate multiplier — 1 = unchanged (e.g. 1.2 = +20%)
172
+ saturation?: number; // Modulate multiplier — 1 = unchanged (e.g. 0 = greyscale)
173
+ hue?: number; // Hue rotation in degrees (e.g. 180 = complementary)
174
+ tint?: string; // Hex colour tint applied via Sharp's tint() (e.g. "#ff0000")
175
+
176
+ // Output (Phase 1)
177
+ quality?: number; // JPEG / WebP quality 1–100 (default: 80)
153
178
  }
154
179
  ```
155
180
 
181
+ ### Pipeline order
182
+
183
+ The Sharp pipeline applies operations in this order:
184
+
185
+ 1. `autoOrient` — apply EXIF orientation
186
+ 2. `extract` — `crop` region (clamped to the auto-oriented input dims). For `shape: '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 reorders `rotate` before a pre-resize `extract`).
187
+ 3. `rotate` — explicit rotation (degrees)
188
+ 4. `flip` / `flop` — vertical / horizontal mirror
189
+ 5. `withMetadata(false)` — strip EXIF (when `stripExif: true`)
190
+ 6. `resize` — longest side to `maxDimension` (maintaining aspect ratio)
191
+ 7. `modulate` — `brightness`, `saturation`, `hue`
192
+ 8. `grayscale`
193
+ 9. `sharpen` / `blur`
194
+ 10. `tint`
195
+ 11. Format encode — `webp` / `jpeg` / `png` with `quality` (PNG forced for circular crops)
196
+ 12. Thumbnail generation (each thumbnail runs the same pipeline with a different `resize` target)
197
+
198
+ ## Route handler factory (`createImageProcessHandler`)
199
+
200
+ `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).
201
+
202
+ ```ts
203
+ // app/api/images/process/route.ts
204
+ import { createImageProcessHandler } from 'hazo_images/server';
205
+ import { createInitializedFileManager } from 'hazo_files/server';
206
+
207
+ export const POST = createImageProcessHandler({
208
+ getFileManager: async () =>
209
+ createInitializedFileManager({ config: { provider: 'local', local: { basePath: './uploads' } } }),
210
+ maxUploadBytes: 15 * 1024 * 1024, // default: 15 MB
211
+ allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
212
+ previewMaxDimension: 1600, // default: 1600 — size of preview returned in response
213
+ savePathPrefix: '/hazo-images', // default: '/hazo-images'
214
+ });
215
+ ```
216
+
217
+ ### Request format (multipart `FormData`)
218
+
219
+ | Field | Type | Required | Description |
220
+ |---|---|---|---|
221
+ | `image` | `File` | yes | The source image file |
222
+ | `options` | JSON string | no | `ProcessImageOptions` to apply |
223
+ | `save` | `"true"` | no | Persist via the file manager |
224
+ | `filename` | string | no | Override the stored filename (otherwise uses uploaded filename) |
225
+
226
+ ### Response JSON
227
+
228
+ **Success:**
229
+ ```json
230
+ {
231
+ "ok": true,
232
+ "metadata": { "width": 1200, "height": 800, "format": "jpeg" },
233
+ "preview": "data:image/jpeg;base64,…",
234
+ "originalPreview": "data:image/jpeg;base64,…",
235
+ "thumbnails": [{ "size": 256, "format": "jpeg", "dataUrl": "data:image/jpeg;base64,…" }],
236
+ "saved": { "path": "/hazo-images/photo.jpg", "url": "https://…" }
237
+ }
238
+ ```
239
+ `saved` is `null` when `save` is not `"true"`. `saveError` (string) is present if the file manager threw but the preview still succeeded.
240
+
241
+ **Error:**
242
+ ```json
243
+ { "ok": false, "error": "Unsupported image format", "errorType": "UnsupportedFormatError" }
244
+ ```
245
+ HTTP status: `UnsupportedFormatError` → 400, `ImageProcessingError` → 422, other → 500.
246
+
247
+ ### Types
248
+
249
+ ```ts
250
+ import type {
251
+ CreateImageProcessHandlerOptions,
252
+ ImageProcessHandlerFileManager,
253
+ } from 'hazo_images/server';
254
+ ```
255
+
256
+ `ImageProcessHandlerFileManager` is a duck-typed interface (`uploadFile` method) — any `hazo_files` FileManager satisfies it.
257
+
258
+ ---
259
+
260
+ ## `<ImageEditorDialog>` (`hazo_images/ui`)
261
+
262
+ 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).
263
+
264
+ > **`"use client"` required.** All exports from `hazo_images/ui` are client components. Import them inside `"use client"` files or dynamic-import with `ssr: false`.
265
+
266
+ ### Minimal usage (uncontrolled, default endpoint)
267
+
268
+ ```tsx
269
+ 'use client';
270
+ import { ImageEditorDialog } from 'hazo_images/ui';
271
+
272
+ export function MyPage() {
273
+ return (
274
+ <ImageEditorDialog
275
+ imageUrl="/uploads/photo.jpg"
276
+ endpoint="/api/images/process"
277
+ onSave={(result) => console.log('saved', result.saved?.path)}
278
+ />
279
+ );
280
+ }
281
+ ```
282
+
283
+ 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.
284
+
285
+ ### Customised controls
286
+
287
+ ```tsx
288
+ import { ImageEditorDialog, DEFAULT_IMAGE_EDIT_CONTROLS } from 'hazo_images/ui';
289
+
290
+ <ImageEditorDialog
291
+ imageUrl="/uploads/photo.jpg"
292
+ endpoint="/api/images/process"
293
+ controls={[
294
+ ...DEFAULT_IMAGE_EDIT_CONTROLS,
295
+ { key: 'tint', label: 'Tint', type: 'color', tab: 'filters', default: '#ffffff' },
296
+ ]}
297
+ defaultOptions={{ quality: 90, maxDimension: 2000 }}
298
+ debounceMs={400}
299
+ labels={{ save: 'Export', cancel: 'Discard' }}
300
+ />
301
+ ```
302
+
303
+ ### Props
304
+
305
+ | Prop | Type | Default | Description |
306
+ |---|---|---|---|
307
+ | `imageUrl` | `string` | — | Source image URL (displayed as "before") |
308
+ | `endpoint` | `string` | — | URL of `createImageProcessHandler` route (use this **or** `onProcess`) |
309
+ | `onProcess` | `(opts: ProcessImageOptions) => Promise<...>` | — | Custom processing callback instead of `endpoint` |
310
+ | `onSave` | `(result) => void` | — | Called after a successful save |
311
+ | `controls` | `ImageEditControl[]` | `DEFAULT_IMAGE_EDIT_CONTROLS` | Declarative control schema (see below) |
312
+ | `defaultOptions` | `ProcessImageOptions` | `{}` | Options pre-applied on open |
313
+ | `debounceMs` | `number` | `300` | Debounce delay (ms) before sending preview request |
314
+ | `labels` | `Partial<EditorLabels>` | — | Override button / tab label strings |
315
+ | `headerSlot` | `React.ReactNode` | — | Extra content in the dialog header |
316
+ | `footerSlot` | `React.ReactNode` | — | Extra content in the dialog footer |
317
+ | `className` | `string` | — | Extra class on the dialog container |
318
+
319
+ 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).
320
+
321
+ 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.
322
+
323
+ 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**).
324
+
325
+ **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`.
326
+
327
+ ### Helper exports
328
+
329
+ ```ts
330
+ import {
331
+ DEFAULT_IMAGE_EDIT_CONTROLS, // ImageEditControl[] — the default control schema
332
+ defaultControlValues, // Record<string, unknown> — zeroed defaults
333
+ controlValuesToOptions, // (values) => ProcessImageOptions
334
+ validate_image_edit_controls // (controls) => string[] — validation errors
335
+ } from 'hazo_images/ui';
336
+ ```
337
+
338
+ ### Control schema types
339
+
340
+ ```ts
341
+ import type { ImageEditControl } from 'hazo_images/ui';
342
+ // Each control: { key, label, type, tab, default, min?, max?, step? }
343
+ // type: 'slider' | 'switch' | 'select' | 'color'
344
+ // tab: 'geometry' | 'filters' | 'output'
345
+ ```
346
+
347
+ ---
348
+
156
349
  ## Runware (AI image generation)
157
350
 
158
351
  `hazo_images/runware` wraps the Runware REST API behind a factory client.
@@ -202,8 +395,11 @@ const { positive, negative } = assemblePrompts({
202
395
  | Version | Scope |
203
396
  |---|---|
204
397
  | `@1.0` | Server pipeline: `processImage` + `uploadProcessedImage` |
205
- | `@1.1+` | UI: redesigned AI-edit component, crop/rotate/filter primitives |
206
- | `@1.2+` | Face detection via `face-api-node` (speculative, non-committed) |
398
+ | `@1.1` | Wave 2 standardisation: `HazoError` subclasses, structured logging, INI config |
399
+ | `@1.2` | `hazo_images/runware` sub-export (AI image generation) |
400
+ | `@1.3` | `blur` Gaussian-blur option on `processImage` |
401
+ | `@1.4` | Extended pipeline options (crop / rotate / flip / flop / grayscale / sharpen / brightness / saturation / hue / tint / quality); `createImageProcessHandler` factory; `hazo_images/ui` (`ImageEditorDialog` + helpers) |
402
+ | `@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 |
207
403
 
208
404
  ## License
209
405
 
package/dist/index.d.ts CHANGED
@@ -6,6 +6,82 @@ interface ProcessImageOptions {
6
6
  maxDimension?: number;
7
7
  webp?: boolean;
8
8
  thumbnails?: number[];
9
+ blur?: number;
10
+ /**
11
+ * Crop (extract) a region from the image. Crop is applied BEFORE rotate/flip,
12
+ * clamped to the auto-oriented input dimensions — out-of-bounds values are
13
+ * silently adjusted rather than thrown. { left, top, width, height } in pixels.
14
+ *
15
+ * `shape` controls the output silhouette (default 'rect'):
16
+ * - 'rect' — free rectangular region.
17
+ * - 'square' — caller supplies an equal-sided box; output is a square rectangle.
18
+ * - 'circle' — a circular alpha mask is applied (corners become transparent)
19
+ * and the output format is forced to PNG (overrides webp/jpeg).
20
+ */
21
+ crop?: {
22
+ left: number;
23
+ top: number;
24
+ width: number;
25
+ height: number;
26
+ shape?: 'rect' | 'square' | 'circle';
27
+ };
28
+ /**
29
+ * Rotate the image by the given number of degrees clockwise.
30
+ * 0 (or omitting the option) skips rotation.
31
+ * Note: `autoRotate` already handles EXIF orientation — this is an explicit
32
+ * additional rotation on top of that.
33
+ */
34
+ rotate?: number;
35
+ /**
36
+ * Flip the image vertically (mirror across the horizontal axis).
37
+ * Default: false (no flip).
38
+ */
39
+ flip?: boolean;
40
+ /**
41
+ * Flop the image horizontally (mirror across the vertical axis).
42
+ * Default: false (no flop).
43
+ */
44
+ flop?: boolean;
45
+ /**
46
+ * Convert the image to grayscale.
47
+ * Default: false.
48
+ */
49
+ grayscale?: boolean;
50
+ /**
51
+ * Apply an unsharp-mask sharpen with the given sigma value (0.000001–10).
52
+ * Higher values = stronger sharpening. Omit or 0 to skip.
53
+ */
54
+ sharpen?: number;
55
+ /**
56
+ * Adjust brightness using Sharp's modulate operation.
57
+ * Multiplier: 1 = unchanged, <1 = darker, >1 = brighter.
58
+ * Omit to leave brightness unchanged.
59
+ */
60
+ brightness?: number;
61
+ /**
62
+ * Adjust saturation (color intensity) using Sharp's modulate operation.
63
+ * Multiplier: 1 = unchanged, <1 = less saturated, >1 = more saturated.
64
+ * Omit to leave saturation unchanged.
65
+ */
66
+ saturation?: number;
67
+ /**
68
+ * Rotate the hue of every pixel using Sharp's modulate operation.
69
+ * Degrees: 0 = unchanged, positive = clockwise rotation around the color wheel.
70
+ * Omit to leave hue unchanged.
71
+ */
72
+ hue?: number;
73
+ /**
74
+ * Tint the image with a hex color string, e.g. "#ff0000" for red.
75
+ * Omit to skip tinting.
76
+ */
77
+ tint?: string;
78
+ /**
79
+ * Output quality for lossy formats (1–100).
80
+ * Applied to WebP output (always) and JPEG output (when input format is jpeg/jpg).
81
+ * PNG and other lossless formats ignore this option.
82
+ * Omit to use Sharp's default quality per format.
83
+ */
84
+ quality?: number;
9
85
  }
10
86
  interface ProcessImageResult {
11
87
  buffer: Buffer;
@@ -13,31 +13,26 @@ function rng() {
13
13
  return rnds8Pool.slice(poolPtr, poolPtr += 16);
14
14
  }
15
15
 
16
- // ../node_modules/uuid/dist/esm-node/regex.js
17
- var regex_default = /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000)$/i;
18
-
19
- // ../node_modules/uuid/dist/esm-node/validate.js
20
- function validate(uuid) {
21
- return typeof uuid === "string" && regex_default.test(uuid);
22
- }
23
- var validate_default = validate;
24
-
25
16
  // ../node_modules/uuid/dist/esm-node/stringify.js
26
17
  var byteToHex = [];
27
18
  for (let i = 0; i < 256; ++i) {
28
- byteToHex.push((i + 256).toString(16).substr(1));
19
+ byteToHex.push((i + 256).toString(16).slice(1));
29
20
  }
30
- function stringify(arr, offset = 0) {
31
- const uuid = (byteToHex[arr[offset + 0]] + byteToHex[arr[offset + 1]] + byteToHex[arr[offset + 2]] + byteToHex[arr[offset + 3]] + "-" + byteToHex[arr[offset + 4]] + byteToHex[arr[offset + 5]] + "-" + byteToHex[arr[offset + 6]] + byteToHex[arr[offset + 7]] + "-" + byteToHex[arr[offset + 8]] + byteToHex[arr[offset + 9]] + "-" + byteToHex[arr[offset + 10]] + byteToHex[arr[offset + 11]] + byteToHex[arr[offset + 12]] + byteToHex[arr[offset + 13]] + byteToHex[arr[offset + 14]] + byteToHex[arr[offset + 15]]).toLowerCase();
32
- if (!validate_default(uuid)) {
33
- throw TypeError("Stringified UUID is invalid");
34
- }
35
- return uuid;
21
+ function unsafeStringify(arr, offset = 0) {
22
+ return byteToHex[arr[offset + 0]] + byteToHex[arr[offset + 1]] + byteToHex[arr[offset + 2]] + byteToHex[arr[offset + 3]] + "-" + byteToHex[arr[offset + 4]] + byteToHex[arr[offset + 5]] + "-" + byteToHex[arr[offset + 6]] + byteToHex[arr[offset + 7]] + "-" + byteToHex[arr[offset + 8]] + byteToHex[arr[offset + 9]] + "-" + byteToHex[arr[offset + 10]] + byteToHex[arr[offset + 11]] + byteToHex[arr[offset + 12]] + byteToHex[arr[offset + 13]] + byteToHex[arr[offset + 14]] + byteToHex[arr[offset + 15]];
36
23
  }
37
- var stringify_default = stringify;
24
+
25
+ // ../node_modules/uuid/dist/esm-node/native.js
26
+ import crypto2 from "crypto";
27
+ var native_default = {
28
+ randomUUID: crypto2.randomUUID
29
+ };
38
30
 
39
31
  // ../node_modules/uuid/dist/esm-node/v4.js
40
32
  function v4(options, buf, offset) {
33
+ if (native_default.randomUUID && !buf && !options) {
34
+ return native_default.randomUUID();
35
+ }
41
36
  options = options || {};
42
37
  const rnds = options.random || (options.rng || rng)();
43
38
  rnds[6] = rnds[6] & 15 | 64;
@@ -49,7 +44,7 @@ function v4(options, buf, offset) {
49
44
  }
50
45
  return buf;
51
46
  }
52
- return stringify_default(rnds);
47
+ return unsafeStringify(rnds);
53
48
  }
54
49
  var v4_default = v4;
55
50
 
@@ -6,6 +6,82 @@ interface ProcessImageOptions {
6
6
  maxDimension?: number;
7
7
  webp?: boolean;
8
8
  thumbnails?: number[];
9
+ blur?: number;
10
+ /**
11
+ * Crop (extract) a region from the image. Crop is applied BEFORE rotate/flip,
12
+ * clamped to the auto-oriented input dimensions — out-of-bounds values are
13
+ * silently adjusted rather than thrown. { left, top, width, height } in pixels.
14
+ *
15
+ * `shape` controls the output silhouette (default 'rect'):
16
+ * - 'rect' — free rectangular region.
17
+ * - 'square' — caller supplies an equal-sided box; output is a square rectangle.
18
+ * - 'circle' — a circular alpha mask is applied (corners become transparent)
19
+ * and the output format is forced to PNG (overrides webp/jpeg).
20
+ */
21
+ crop?: {
22
+ left: number;
23
+ top: number;
24
+ width: number;
25
+ height: number;
26
+ shape?: 'rect' | 'square' | 'circle';
27
+ };
28
+ /**
29
+ * Rotate the image by the given number of degrees clockwise.
30
+ * 0 (or omitting the option) skips rotation.
31
+ * Note: `autoRotate` already handles EXIF orientation — this is an explicit
32
+ * additional rotation on top of that.
33
+ */
34
+ rotate?: number;
35
+ /**
36
+ * Flip the image vertically (mirror across the horizontal axis).
37
+ * Default: false (no flip).
38
+ */
39
+ flip?: boolean;
40
+ /**
41
+ * Flop the image horizontally (mirror across the vertical axis).
42
+ * Default: false (no flop).
43
+ */
44
+ flop?: boolean;
45
+ /**
46
+ * Convert the image to grayscale.
47
+ * Default: false.
48
+ */
49
+ grayscale?: boolean;
50
+ /**
51
+ * Apply an unsharp-mask sharpen with the given sigma value (0.000001–10).
52
+ * Higher values = stronger sharpening. Omit or 0 to skip.
53
+ */
54
+ sharpen?: number;
55
+ /**
56
+ * Adjust brightness using Sharp's modulate operation.
57
+ * Multiplier: 1 = unchanged, <1 = darker, >1 = brighter.
58
+ * Omit to leave brightness unchanged.
59
+ */
60
+ brightness?: number;
61
+ /**
62
+ * Adjust saturation (color intensity) using Sharp's modulate operation.
63
+ * Multiplier: 1 = unchanged, <1 = less saturated, >1 = more saturated.
64
+ * Omit to leave saturation unchanged.
65
+ */
66
+ saturation?: number;
67
+ /**
68
+ * Rotate the hue of every pixel using Sharp's modulate operation.
69
+ * Degrees: 0 = unchanged, positive = clockwise rotation around the color wheel.
70
+ * Omit to leave hue unchanged.
71
+ */
72
+ hue?: number;
73
+ /**
74
+ * Tint the image with a hex color string, e.g. "#ff0000" for red.
75
+ * Omit to skip tinting.
76
+ */
77
+ tint?: string;
78
+ /**
79
+ * Output quality for lossy formats (1–100).
80
+ * Applied to WebP output (always) and JPEG output (when input format is jpeg/jpg).
81
+ * PNG and other lossless formats ignore this option.
82
+ * Omit to use Sharp's default quality per format.
83
+ */
84
+ quality?: number;
9
85
  }
10
86
  interface ProcessImageResult {
11
87
  buffer: Buffer;
@@ -39,6 +115,42 @@ interface UploadOptions {
39
115
  }
40
116
  declare function uploadProcessedImage(fm: FileManager, buffer: Buffer, virtualPath: string, opts?: ProcessImageOptions & UploadOptions): Promise<UploadProcessedImageResult>;
41
117
 
118
+ /**
119
+ * Framework-agnostic Web handler factory for the hazo_images processing pipeline.
120
+ *
121
+ * Uses only Web-standard Request/Response/FormData — works as a Next.js App-Router
122
+ * route POST directly. Does NOT import `next`, `hazo_ui`, or `hazo_files`.
123
+ *
124
+ * @since 1.4.0
125
+ */
126
+ /**
127
+ * Duck-typed FileManager interface — structurally matches hazo_files FileManager
128
+ * and TrackedFileManager. No compile-time dep on hazo_files.
129
+ */
130
+ interface ImageProcessHandlerFileManager {
131
+ uploadFile(source: Buffer | string, remotePath: string, options?: Record<string, unknown>): Promise<{
132
+ success: boolean;
133
+ data?: unknown;
134
+ error?: string;
135
+ }>;
136
+ }
137
+ interface CreateImageProcessHandlerOptions {
138
+ /**
139
+ * Provide to enable persistence on `save`.
140
+ * Returns the FileManager (sync or async).
141
+ */
142
+ getFileManager?: () => Promise<ImageProcessHandlerFileManager> | ImageProcessHandlerFileManager;
143
+ /** Max upload size in bytes. Default 15 MB. */
144
+ maxUploadBytes?: number;
145
+ /** Allowed input MIME types. */
146
+ allowedTypes?: string[];
147
+ /** Cap the longest side of the base64 preview to bound payload size. Default 1600. */
148
+ previewMaxDimension?: number;
149
+ /** Base virtual path prefix used when saving. Default '/hazo-images'. */
150
+ savePathPrefix?: string;
151
+ }
152
+ declare function createImageProcessHandler(opts?: CreateImageProcessHandlerOptions): (request: Request) => Promise<Response>;
153
+
42
154
  /**
43
155
  * hazo_images error classes.
44
156
  *
@@ -90,4 +202,43 @@ declare class ImageUploadError extends HazoExternalError {
90
202
  constructor(message: string, context?: Record<string, unknown>);
91
203
  }
92
204
 
93
- export { ImageProcessingError, ImageUploadError, type ProcessImageOptions, type ProcessImageResult, SharpMissingError, UnsupportedFormatError, type UploadProcessedImageResult, processImage, uploadProcessedImage };
205
+ interface DeriveVariantOptions extends ProcessImageOptions {
206
+ actor_id?: string;
207
+ /**
208
+ * Virtual path suffix appended to the source path to form the variant path.
209
+ * Default: derived from the operation (e.g. '_crop', '_blur3', '_thumb', '_derived').
210
+ */
211
+ variantSuffix?: string;
212
+ }
213
+ interface DeriveVariantDeps {
214
+ /** Must support: uploadFile, downloadFile */
215
+ fm: {
216
+ uploadFile(source: Buffer | string, remotePath: string, opts?: Record<string, unknown>): Promise<{
217
+ success: boolean;
218
+ data?: unknown;
219
+ error?: string;
220
+ }>;
221
+ downloadFile(fileId: string): Promise<Buffer>;
222
+ };
223
+ metadataService?: {
224
+ findById(id: string): Promise<{
225
+ virtual_path?: string;
226
+ file_name?: string;
227
+ } | null>;
228
+ };
229
+ }
230
+ interface DeriveVariantResult {
231
+ /** The file_id of the newly uploaded variant (from fm.uploadFile data.file_id or similar) */
232
+ file_id: string;
233
+ /** Optional — not known at this layer; caller resolves URL separately */
234
+ url?: string;
235
+ }
236
+ /**
237
+ * deriveVariant — fileId-in/fileId-out convenience over processImage + uploadProcessedImage.
238
+ *
239
+ * Downloads the source file by ID, processes it with processImage, uploads the
240
+ * result via fm.uploadFile, and returns the new file_id.
241
+ */
242
+ declare function deriveVariant(sourceFileId: string, deps: DeriveVariantDeps, opts?: DeriveVariantOptions): Promise<DeriveVariantResult>;
243
+
244
+ export { type CreateImageProcessHandlerOptions, type DeriveVariantDeps, type DeriveVariantOptions, type DeriveVariantResult, type ImageProcessHandlerFileManager, ImageProcessingError, ImageUploadError, type ProcessImageOptions, type ProcessImageResult, SharpMissingError, UnsupportedFormatError, type UploadProcessedImageResult, createImageProcessHandler, deriveVariant, processImage, uploadProcessedImage };