@gmitch215/tinyimg 1.0.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/README.md ADDED
@@ -0,0 +1,720 @@
1
+ # 🏞️ tinyimg
2
+
3
+ > Lightweight & fast image processing library for Cloudflare Workers
4
+
5
+ tinyimg decodes, transforms, draws on and re-encodes images inside a Worker, with no binding, no
6
+ subrequest and no per-transformation charge. It is freestanding C compiled to one wasm32 module,
7
+ wrapped by a TypeScript package.
8
+
9
+ Cloudflare Images bills per transformation, and the Workers runtime has no Canvas API: no
10
+ `OffscreenCanvas`, no `createImageBitmap`, no 2D context. tinyimg does both jobs inside the Worker's
11
+ own CPU budget.
12
+
13
+ ## Table of Contents
14
+
15
+ - [Why tinyimg](#why-tinyimg)
16
+ - [Compared With Cloudflare Images](#compared-with-cloudflare-images)
17
+ - [Install](#install)
18
+ - [Getting Started](#getting-started)
19
+ - [Transformations](#transformations)
20
+ - [Chaining](#chaining)
21
+ - [The Planner](#the-planner)
22
+ - [Codec Support](#codec-support)
23
+ - [Drawing](#drawing)
24
+ - [Effects](#effects)
25
+ - [Text](#text)
26
+ - [Face Detection](#face-detection)
27
+ - [Color Management](#color-management)
28
+ - [Reading the dB Figures](#reading-the-db-figures)
29
+ - [Budget and Effort](#budget-and-effort)
30
+ - [Caching](#caching)
31
+ - [Work Counters](#work-counters)
32
+ - [Blobs](#blobs)
33
+ - [Error Handling](#error-handling)
34
+ - [Size](#size)
35
+ - [Platform Limits](#platform-limits)
36
+ - [Out of Scope](#out-of-scope)
37
+ - [API Reference](#api-reference)
38
+ - [Contributing](#contributing)
39
+ - [License](#license)
40
+
41
+ ## Why tinyimg
42
+
43
+ - **No per-transformation cost.** A Worker serving many derivatives of many images pays per
44
+ derivative through Images. tinyimg does the same work for CPU time.
45
+ - **Workers-native.** One wasm module with zero imports, instantiated at worker startup. Nothing
46
+ depends on Node, and there is no filesystem access, no subrequest and no binding.
47
+ - **Lazy by design.** Operations do not run when called. They append to a plan, and the plan decides
48
+ what to decode before decoding it; see [The Planner](#the-planner).
49
+ - **A drawing surface.** Shapes, polygons, gradients, compositing with blend modes, and a display
50
+ list with a transform stack, which is the Canvas-shaped API the runtime does not have.
51
+ - **Nothing bundled that you might not use.** Fonts, color profiles and cascades load at runtime.
52
+ The module ships no font data.
53
+ - **TypeScript-first.** The types are the contract and the documentation.
54
+
55
+ ## Compared With Cloudflare Images
56
+
57
+ Both do the same job in different places. Images Transformations runs on Cloudflare's own pipeline and
58
+ bills per transformation. tinyimg runs in your Worker's isolate and bills as CPU time, which on the
59
+ Workers Paid plan is a resource the $5 subscription already includes 30 million milliseconds of.
60
+
61
+ | | Cloudflare Images | tinyimg |
62
+ | --------------------- | --------------------------------------- | ------------------------------------ |
63
+ | Charge per transform | $0.50 / 1,000 unique, 5,000 free / mo | none |
64
+ | What you pay instead | nothing else | Worker CPU time |
65
+ | Billing granularity | unique source and flags, once per month | every invocation that is not cached |
66
+ | Subrequest or binding | required | none |
67
+ | AVIF output | yes | no |
68
+ | Runs on Workers Free | 5,000 transforms / mo | sources up to roughly 2 MP |
69
+ | Raw pixel access | no | yes |
70
+ | Shapes and gradients | no | yes |
71
+ | Text | one string, one font, one size | layout, wrapping, alignment, metrics |
72
+
73
+ ### Cost
74
+
75
+ Past the included CPU, Workers bills $0.02 per million CPU milliseconds, so a transformation costs
76
+ its own compute multiplied by that. Three routes to the same 800 px page:
77
+
78
+ | Route | `effort` | Compute | Per million | Within the included CPU |
79
+ | ----------- | -------- | ------: | ----------: | ----------------------: |
80
+ | 800 px WebP | fancy | 41.6 ms | $0.83 | 721,000 |
81
+ | 800 px WebP | fast | 33.4 ms | $0.67 | 898,000 |
82
+ | 800 px JPEG | either | 20.8 ms | $0.42 | 1,442,000 |
83
+
84
+ Against **$500 per million** for Images at $0.50 per 1,000. The absolute figures are small whichever
85
+ route you take. What the spread buys is CPU headroom, and headroom is what the Free plan rations.
86
+
87
+ Images bills a unique source and flag combination **once per calendar month** however often it is
88
+ served, so a small set of derivatives under heavy traffic is already cheap there. tinyimg charges for
89
+ every invocation that runs, so put [Workers Cache](https://developers.cloudflare.com/workers/cache/)
90
+ in front of it and a hit costs no CPU at all. tinyimg wins on many distinct derivatives, Images wins
91
+ on few you have not cached.
92
+
93
+ ### Speed
94
+
95
+ Median wall clock in bun on darwin-arm64 over `sf-24.jpg` at 1835x1032. This is not Worker CPU time
96
+ on Cloudflare's hardware, so profile your own images.
97
+
98
+ | Request | `effort: 'fancy'` | `effort: 'fast'` |
99
+ | ---------------------- | ----------------: | ---------------: |
100
+ | 200 px cover, JPEG q80 | 7.2 ms | 6.9 ms |
101
+ | 200 px cover, WebP q80 | 8.3 ms | 7.7 ms |
102
+ | 400 px cover, JPEG q80 | 11.7 ms | 11.8 ms |
103
+ | 400 px cover, WebP q80 | 17.2 ms | 14.9 ms |
104
+ | 800 px cover, JPEG q80 | 20.8 ms | 20.8 ms |
105
+ | 800 px cover, WebP q80 | 41.6 ms | 33.4 ms |
106
+
107
+ `effort` moves WebP at every size and leaves JPEG output flat, because the WebP encoder has a 4x4
108
+ prediction search to bound and the JPEG encoder has no equivalent.
109
+
110
+ JPEG's own lever is on the decode side, and a thumbnail cannot show it. Replicating chroma instead
111
+ of filtering it is worth 1.11x to 1.25x on a full decode, but the saving scales with output samples
112
+ while a reduced request's cost is dominated by the entropy decode, which the source fixes. On a
113
+ 4:2:0 source it measures 1.00x at 200 px and 1.07x at the source's own extent. `sf-24.jpg` cannot
114
+ show it at any size, being 4:4:4 with no chroma to upsample.
115
+
116
+ A request the source already satisfies is 0.01 ms and a plan decision with no pixels decoded is
117
+ under 0.1 ms; neither depends on effort.
118
+
119
+ The Workers Free plan allows 10 ms of CPU per request and does not let a caller pay for more, so a
120
+ request either fits or fails. Workers Paid defaults to 30 seconds, raised to 5 minutes with
121
+ `limits.cpu_ms`, and nothing above comes close to it.
122
+
123
+ **What fits Free is decided by the source, not by the size you ask for.** Every row below produces
124
+ the same 200 px thumbnail from the same eighth-scale decode, and every one of them decodes 1.57% of
125
+ its source's samples. The spread is the entropy decode, which no output size can reduce.
126
+
127
+ | Source | Megapixels | 200 px wide, JPEG |
128
+ | --------- | ---------: | ----------------: |
129
+ | 320x180 | 0.06 | 2.20 ms |
130
+ | 1835x1032 | 1.89 | 6.80 ms |
131
+ | 1920x1250 | 2.40 | 15.86 ms |
132
+ | 5000x4000 | 20.00 | 25.41 ms |
133
+
134
+ So roughly two megapixels is the Free ceiling for a synchronous transformation, whatever the output
135
+ size. Past that the artifact has to be computed once and cached; see [Caching](#caching).
136
+
137
+ Decode is 1.2x to 2.1x behind libjpeg, libpng and libwebp, which have SIMD implementations of their
138
+ hot kernels and tinyimg has none. WebP and PNG encode are slower still.
139
+ [`TECHNICAL_REPORT.md`](https://github.com/gmitch215/tinyimg/blob/master/TECHNICAL_REPORT.md) has
140
+ the per-stage profile and the comparison against `@jsquash`.
141
+
142
+ ### Gaps
143
+
144
+ - **No AVIF output.** Images serves AVIF through `format=auto`. tinyimg parses the container and
145
+ reports its dimensions, and encodes WebP instead, which lands 25-35% below JPEG at matched quality.
146
+ See [Out of Scope](#out-of-scope).
147
+ - **No animation.** Both decode a first frame; neither re-times or composes one.
148
+ - **You own the caching.** Images caches derivatives at the edge as a product feature. tinyimg gives
149
+ you the key and leaves the cache to you; see [Caching](#caching).
150
+
151
+ ### What tinyimg Adds
152
+
153
+ Images covers resize, fit, gravity, the standard adjustments, image overlays with Porter-Duff
154
+ compositing, and a rasterized text string. Past that:
155
+
156
+ - **A drawing surface.** Lines, rectangles, rounded rectangles, circles, ellipses, polygons with both
157
+ fill rules, linear and radial gradients, and a display list with a transform matrix stack.
158
+ - **Text as layout.** Wrapping, alignment, text boxes and measurement, against Images' single styled
159
+ string.
160
+ - **Raw pixels.** `decode` and `Image.pixels` hand back samples, so anything not in the operation set
161
+ is still reachable.
162
+ - **Effects and warps.** Sobel, emboss, morphology, median, dither, halftone, duotone, split-tone,
163
+ color blindness simulation, curves, channel mixer, shear, perspective, barrel and swirl.
164
+ - **Face boxes, not just face gravity.** `detectFaces` returns rectangles, and `tiny_image_blur_faces`
165
+ and `tiny_image_pixelate_faces` act on them.
166
+ - **Analysis.** Histogram, dominant color, palette extraction and a perceptual hash.
167
+
168
+ The chainable `Image` class covers the transformation set. Drawing, text, the effects and the
169
+ analysis helpers are module exports, reached through `tinyimg.exports` with the same names the
170
+ headers use.
171
+
172
+ ## Install
173
+
174
+ ```sh
175
+ npm install @gmitch215/tinyimg
176
+ ```
177
+
178
+ ```sh
179
+ bun add @gmitch215/tinyimg
180
+ ```
181
+
182
+ ## Getting Started
183
+
184
+ The module has to arrive already compiled. workerd refuses `WebAssembly.Module(bytes)`, so importing
185
+ the wasm file is what makes it work: the runtime compiles it at worker startup.
186
+
187
+ ```ts
188
+ import wasm from '@gmitch215/tinyimg/tinyimg.wasm';
189
+ import { TinyImgModule, transform } from '@gmitch215/tinyimg';
190
+
191
+ const tinyimg = TinyImgModule.load(wasm);
192
+
193
+ export default {
194
+ async fetch(request: Request): Promise<Response> {
195
+ const source = await fetch('https://example.com/photo.jpg');
196
+
197
+ const thumb = await transform(tinyimg, source, {
198
+ width: 400,
199
+ height: 400,
200
+ fit: 'cover',
201
+ format: 'webp',
202
+ quality: 80
203
+ });
204
+
205
+ return thumb.response({ 'cache-control': 'public, max-age=86400' });
206
+ }
207
+ };
208
+ ```
209
+
210
+ Under node, bun or a browser, compile from bytes instead:
211
+
212
+ ```ts
213
+ import { readFileSync } from 'node:fs';
214
+ import { TinyImgModule } from '@gmitch215/tinyimg';
215
+
216
+ const tinyimg = await TinyImgModule.loadBytes(readFileSync('tinyimg.wasm'));
217
+ ```
218
+
219
+ Input accepts a `Uint8Array`, an `ArrayBuffer`, a `Blob`, a `Response` or a `ReadableStream`. Results
220
+ carry `.bytes()`, `.blob()`, `.response()` and `.dataUrl()`.
221
+
222
+ ## Transformations
223
+
224
+ `transform` takes one option object. The names mirror Cloudflare Images' own parameters.
225
+
226
+ | Option | Type | What it does |
227
+ | ----------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------- |
228
+ | `width`, `height` | `number` | Target extent. One alone keeps the aspect ratio. |
229
+ | `fit` | `Fit` | How an aspect mismatch is absorbed. Read when both extents are given. |
230
+ | `gravity` | `Gravity` | Which part a crop keeps, or where a pad puts the image. |
231
+ | `filter` | `ResampleFilter` | Weights the resample reads through. |
232
+ | `crop` | `Rect` | Source rectangle, taken before anything else. |
233
+ | `dpr` | `number` | Multiplies `width` and `height`. |
234
+ | `rotate` | `0 \| 90 \| 180 \| 270` | Quarter turns clockwise. |
235
+ | `flip` | `'horizontal' \| 'vertical' \| 'both'` | Which axes to mirror. |
236
+ | `brightness`, `contrast`, `saturation`, `gamma` | `number` | 1 is unchanged. |
237
+ | `hue` | `number` | Degrees. |
238
+ | `grayscale`, `invert` | `boolean` | |
239
+ | `blur` | `number` | Gaussian radius in pixels. |
240
+ | `sharpen` | `number` | Unsharp mask amount. |
241
+ | `trim` | `boolean \| number` | Trims a uniform border, at this tolerance. |
242
+ | `background` | `Color` | Fills whatever a pad or a rotation leaves empty. |
243
+ | `format` | `ImageFormat` | Container to encode as. Defaults to the source's own. |
244
+ | `quality` | `number` | 1 through 100, for a lossy format. |
245
+ | `lossless`, `progressive` | `boolean` | Where the format has the mode. |
246
+ | `metadata` | `'keep' \| 'none'` | Whether EXIF survives the encode. Defaults to `keep`. |
247
+
248
+ The options apply in a fixed order, exported as `TRANSFORM_ORDER`: crop, resize or fit, rotate, flip,
249
+ the color adjustments, blur, sharpen, trim. An option object has no order of its own, so two callers
250
+ writing the same keys get the same image.
251
+
252
+ ### Fit Modes
253
+
254
+ | Mode | Absorbs the mismatch by | Scale may |
255
+ | ---------------- | ------------------------------ | ------------ |
256
+ | `scale-down` | leaving it | only fall |
257
+ | `contain` | leaving it | rise or fall |
258
+ | `scale-up` | leaving it | only rise |
259
+ | `cover` | cropping | rise or fall |
260
+ | `crop` | cropping | only fall |
261
+ | `fill` | cropping | only rise |
262
+ | `aspect-crop` | cropping | neither |
263
+ | `aspect-cover` | cropping to the target's shape | neither |
264
+ | `pad` | padding | rise or fall |
265
+ | `aspect-contain` | padding to the target's shape | neither |
266
+ | `stretch` | distorting | rise or fall |
267
+
268
+ ### Gravity
269
+
270
+ The nine fixed positions are `center`, `north`, `south`, `west`, `east`, `north-west`, `north-east`,
271
+ `south-west` and `south-east`.
272
+
273
+ Two are computed from the image. `auto` weights every tile by local detail and centers on the
274
+ centroid, so a photograph with one sharp subject on a soft background focuses on the subject. `face`
275
+ runs the detector and centers on what it finds, falling back to `auto` when no cascade is loaded or
276
+ nothing is found.
277
+
278
+ ## Chaining
279
+
280
+ `Image` is the same planner with a method per operation, for when you want two encodings of one
281
+ transformation or an operation the option object does not cover.
282
+
283
+ ```ts
284
+ import { Image } from '@gmitch215/tinyimg';
285
+
286
+ using image = await Image.open(tinyimg, request.body);
287
+
288
+ image.crop(400, 200, 900, 600).resize(300, 200).brightness(1.2).sharpen(1);
289
+
290
+ const webp = await image.bytes('webp', { quality: 80 });
291
+ const png = await image.bytes('png');
292
+ ```
293
+
294
+ The handle lives in the module's memory, so `using` is the shortest correct way to hold one. Without
295
+ it, call `dispose()` in a `finally`. Encoding does not consume the plan.
296
+
297
+ `pixels()` hands back the raw samples for a caller who wants them rather than a container.
298
+
299
+ ## The Planner
300
+
301
+ Nothing runs until the output is asked for. Operations append to a fixed-capacity list, and the plan
302
+ runs once:
303
+
304
+ ```ts
305
+ image.crop(2000, 1500, 500, 500).resize(100, 100);
306
+ image.brightness(1.2).contrast(1.1).saturation(0.8).gamma(2.2);
307
+ ```
308
+
309
+ - The output needs a 500x500 source rectangle out of 16 megapixels, so the decoder is asked for that
310
+ rectangle and nothing else.
311
+ - A 100x100 output from a 500x500 source needs no detail above a quarter, so JPEG decodes at 1/4
312
+ through a reduced IDCT.
313
+ - Brightness, contrast and gamma become one 256-entry table; saturation becomes one matrix.
314
+ - Six traversals become one pass over 10,000 output pixels.
315
+ - An added `brightness(1.0)` never runs.
316
+
317
+ `decide()` reports all of it without producing the image:
318
+
319
+ ```ts
320
+ using image = await Image.open(tinyimg, source);
321
+ image.crop(400, 200, 900, 600).resize(300, 200);
322
+
323
+ const decided = image.decide();
324
+ // { region: { x: 400, y: 200, width: 900, height: 600 }, scale: 4,
325
+ // decoded: { width: 225, height: 150 }, output: { width: 300, height: 200, channels: 3 },
326
+ // eliminated: 0, collapsed: 0, colorStages: 0, passes: 1,
327
+ // kernels: ['region', 'scaled', 'resample'] }
328
+ ```
329
+
330
+ On that chain the planner is worth 3.63x against running the same operations one pass each.
331
+
332
+ ## Codec Support
333
+
334
+ | Format | Decode | Encode |
335
+ | ---------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
336
+ | PNG | 8 and 16 bit, gray/palette/truecolor with and without alpha, Adam7 | hash-chain LZ77, static and dynamic Huffman, the five filters |
337
+ | JPEG | baseline and progressive, 4:4:4 / 4:2:2 / 4:2:0, CMYK and YCCK, restart markers | quality-scaled tables, optimized Huffman, progressive scan script |
338
+ | WebP | VP8 lossy and VP8L lossless | both modes |
339
+ | GIF | LZW, local and global palettes, interlace, transparency | median-cut palette with Floyd-Steinberg dithering |
340
+ | TIFF | strips, uncompressed / PackBits / LZW / Deflate, both byte orders | uncompressed and PackBits |
341
+ | BMP | uncompressed and RLE8 | uncompressed |
342
+ | AVIF, HEIF | container only; `probe` answers and decode reports a specific error | no |
343
+
344
+ JPEG carries true DCT-domain scaled decode at 1/2, 1/4 and 1/8, and region decode that Huffman-scans
345
+ past blocks outside the box without transforming them. PNG, GIF, TIFF and BMP row-skip and
346
+ column-skip.
347
+
348
+ `probe` reads headers only, for every format including the ones the library cannot decode:
349
+
350
+ ```ts
351
+ const info = await tinyimg.probe(source);
352
+ // { width, height, frames, format, channels, bitDepth, hasAlpha, progressive }
353
+ ```
354
+
355
+ ## Drawing
356
+
357
+ Pixels, lines with thickness, rectangles, rounded rectangles, circles, ellipses, and polygons filled
358
+ by scanline with either the even-odd or the nonzero rule. Linear and radial gradients, borders,
359
+ per-channel color replacement, and image compositing with opacity, tiling and edge offsets.
360
+
361
+ Compositing carries the CSS separable blend modes: multiply, screen, overlay, darken, lighten,
362
+ hard-light, soft-light, difference, exclusion, add and subtract, composed in premultiplied form so a
363
+ stack of partly transparent layers ends up as transparent as it should be.
364
+
365
+ A display list keeps shapes symbolic behind a 2x3 affine stack until one rasterization, and drops a
366
+ shape that falls outside the target or that a later opaque shape completely covers.
367
+
368
+ ## Effects
369
+
370
+ Roughly ninety, reaching the planner through three generic operations, so they compose into one
371
+ matrix and one table wherever they can.
372
+
373
+ - **Tone and color.** negate, grayscale, black and white, colorize, tint, posterize, threshold,
374
+ solarize, duotone, split-tone, exposure, fill light, temperature and tint white balance, vibrance,
375
+ levels, curves from control points, color balance by band, channel mixer, per-channel gain,
376
+ colorblind simulation and assist, and nine named presets.
377
+ - **Spatial.** unsharp mask, clarity, sobel, emboss, pixelate, median despeckle, morphology, outline,
378
+ motion / radial / zoom blur, tilt-shift, region blur, drop shadow, glow, gradient fade, noise, film
379
+ grain, ordered dither, halftone, chromatic aberration, scanlines.
380
+ - **Auto-correction.** auto brightness, contrast, color, levels and gamma; improve; shadow and
381
+ highlight recovery; dehaze by dark-channel prior.
382
+ - **Geometry.** shear, quad and perspective distort by inverse homography, arc warp, barrel and
383
+ pincushion, swirl, polar, corner radius.
384
+ - **Analysis.** histogram, dominant color, palette extraction, average color, and a 64-bit
385
+ perceptual hash with a Hamming-distance helper.
386
+
387
+ ## Text
388
+
389
+ TrueType and OpenType with `glyf` outlines, plus PSF and BDF bitmap faces, dispatched from the magic
390
+ bytes. Quadratic flattening and a scanline fill with four vertical subsamples and exact horizontal
391
+ coverage.
392
+
393
+ ```ts
394
+ const font = await env.BLOBS.get('fonts/DejaVuSans.ttf');
395
+ if (font) await tinyimg.loadBlob('font', 'sans', font.body);
396
+ ```
397
+
398
+ `tiny_image_draw_text`, `tiny_image_draw_text_box` with wrapping and alignment, and `tiny_text_measure`
399
+ share one layout walk, so measuring and then drawing at the measured position lands where the
400
+ measurement said. Kern pairs are applied when the face carries a `kern` table.
401
+
402
+ 100 characters at 16 px cost 0.156 ms.
403
+
404
+ ## Face Detection
405
+
406
+ An LBP cascade over an integral image, multi-scale, with union-find grouping. The cascades are
407
+ OpenCV's `lbpcascade_frontalface_improved.xml` and `lbpcascade_profileface.xml`.
408
+
409
+ The module carries no cascade data and no XML parser, so a cascade arrives as bytes in a packed form
410
+ that `scripts/fixtures.ts` builds from the OpenCV source: 7.4 kB for the frontal cascade against 54 kB
411
+ of XML. `loadBlob` rejects one still in XML. Both packed cascades are published, and
412
+ `bun run blobs` rebuilds them if you would rather host your own:
413
+
414
+ ```ts
415
+ const frontal = await fetch('https://cdn.gmitch215.dev/tinyimg/cascades/lbp-frontalface.bin');
416
+ const profile = await fetch('https://cdn.gmitch215.dev/tinyimg/cascades/lbp-profileface.bin');
417
+
418
+ await tinyimg.loadBlob('cascade', 'frontal', await frontal.arrayBuffer());
419
+ await tinyimg.loadBlob('cascade', 'profile', await profile.arrayBuffer());
420
+
421
+ const faces = await tinyimg.detectFaces(source);
422
+ // [{ x, y, width, height, neighbors }]
423
+ ```
424
+
425
+ Every resident cascade runs and the results are grouped together, so a frontal and a profile cascade
426
+ find both kinds of face and a face that fires both is one box. Detections match OpenCV's own detector
427
+ within a pixel on identical pixels.
428
+
429
+ Feeds `gravity: 'face'`. With no cascade loaded, `detectFaces` reports a `TinyImgBlobError` and
430
+ `gravity: 'face'` falls back to `auto`.
431
+
432
+ ## Color Management
433
+
434
+ ICC matrix-and-TRC profiles: the `rXYZ` / `gXYZ` / `bXYZ` primaries, `curv` and parametric `para`
435
+ tone curves, and Bradford chromatic adaptation folded into the matrix. Conversion between a tagged
436
+ profile and sRGB agrees with ImageMagick at 49 dB.
437
+
438
+ ### Reading the dB Figures
439
+
440
+ Every accuracy number here and in the technical report is PSNR against a stated reference:
441
+ `10 * log10(255^2 / MSE)`, where the mean squared error runs over every channel sample of both
442
+ images. It is a log scale, so 6 dB is one bit of precision and each 10 dB is a tenfold drop in
443
+ squared error. Higher is closer, and infinity means the two images are byte-identical.
444
+
445
+ Where the thresholds fall, for 8-bit images:
446
+
447
+ | PSNR | What it looks like |
448
+ | ----------- | ------------------------------------------------------------------------------- |
449
+ | above 50 dB | below one level of quantization; no viewer can distinguish the two |
450
+ | 40 to 50 dB | indistinguishable in normal viewing, and separable in a difference blend |
451
+ | 30 to 40 dB | artifacts findable if you look for them, around the quality of a JPEG at q75-85 |
452
+ | below 30 dB | visible on inspection |
453
+
454
+ Those bands are the conventional reading of the scale rather than something measured here, and PSNR
455
+ scores per-sample error without regard to where the error sits. A few badly wrong pixels and a faint
456
+ haze across the whole frame can score the same while looking nothing alike, which is why the codec
457
+ tests assert a floor per fixture instead of one number for the library. The floors that matter are
458
+ the ones the comparison is stated at: 49 dB for an ICC conversion is a rounding difference, and the
459
+ 24 dB floor on a glyph shape is a hinting difference measured against FreeType.
460
+
461
+ LUT-based A2B and B2A profiles, which is mostly CMYK printer profiles, are rejected with a specific
462
+ error rather than approximated. The matrix and TRC path covers sRGB, Display P3, Adobe RGB and
463
+ Rec.2020, the profiles web images carry.
464
+
465
+ ## Budget and Effort
466
+
467
+ Three things let a caller work inside a CPU limit instead of discovering it.
468
+
469
+ **A request the source already satisfies costs a header read.** `transform` compares the request
470
+ against the source's header first, and hands the original bytes back when there is nothing to do. A
471
+ 600 px bound on a 400 px WebP decodes nothing.
472
+
473
+ **`effort: 'fast'` bounds the work rather than the quality.** Quality says what the output should
474
+ look like; effort says how hard to work to get there. It applies to the decode, the resize and the
475
+ encode, and each part gives up something measured:
476
+
477
+ | Stage | What `fast` does | Speed | Quality |
478
+ | ------ | ------------------------------------------- | -------------- | --------------- |
479
+ | Decode | WebP skips deblocking | 1.53x | 46.8 dB |
480
+ | Decode | JPEG replicates chroma instead of filtering | 1.11x to 1.25x | 43.6 to 59.5 dB |
481
+ | Resize | Bilinear instead of Catmull-Rom, enlarging | 3.2x | 45.7 dB |
482
+ | Encode | WebP bounds the 4x4 prediction search | 1.18x to 1.45x | within 0.05 dB |
483
+
484
+ **Only a lossy format has anything to trade.** A lossless one defines its pixels exactly, so every
485
+ step is needed to produce them: PNG, GIF, TIFF and lossless WebP decode identically at either
486
+ effort, and so does a 4:4:4 JPEG, which has no chroma to upsample. A reduction also keeps its
487
+ filter, because an area average is already both the cheapest and the right answer; only enlarging
488
+ has a filter to step down.
489
+
490
+ A filter you name is never substituted, and neither is a format. Effort decides what the library
491
+ left open, not what you asked for.
492
+
493
+ End to end, on requests through the whole pipeline:
494
+
495
+ | Request | `fancy` | `fast` | |
496
+ | ----------------------------- | ------: | ------: | ----: |
497
+ | 200 px, WebP source, WebP out | 18.5 ms | 13.5 ms | 1.38x |
498
+ | 800 px, WebP source, WebP out | 54.6 ms | 42.2 ms | 1.29x |
499
+ | 800 px, JPEG source, WebP out | 42.2 ms | 34.0 ms | 1.24x |
500
+ | 800 px, JPEG source, JPEG out | 21.6 ms | 21.5 ms | 1.01x |
501
+
502
+ A WebP source gains most, because it is the only one where both the decode and the encode have
503
+ something to give. The last row is the contract working rather than a failure: a 4:4:4 JPEG decoded
504
+ and re-encoded as JPEG has no deblocking pass, no chroma to replicate and no prediction search, so
505
+ there is nothing for effort to spend.
506
+
507
+ Encoded size can go either way under `fast`, because the per-subblock choice minimizes prediction
508
+ error rather than rate: a diagonal mode can win on error and then cost more bits to code. Flat-color
509
+ illustrations pay the most, and hard diagonal edges are what those modes exist for.
510
+
511
+ **`decide().estimateMs` prices a plan before it runs, and `budgetMs` acts on it.** The encoders differ
512
+ by roughly a factor of five per sample, so `format: 'auto'` with a budget takes WebP when it fits and
513
+ JPEG when it does not:
514
+
515
+ ```ts
516
+ const result = await transform(tinyimg, source, {
517
+ width: 400,
518
+ format: 'auto',
519
+ budgetMs: 7,
520
+ effort: 'fast'
521
+ });
522
+
523
+ result.format; // 'webp' if it fits inside 7 ms, otherwise 'jpeg'
524
+ ```
525
+
526
+ A format you name is never substituted, however far over budget it is. The estimate is accurate to
527
+ about 20% on the machine its rates were measured on, so give a budget below the limit rather than at
528
+ it, and use [`measure`](#work-counters) when you need what a request cost.
529
+
530
+ ## Caching
531
+
532
+ A cache hit costs no CPU at all, which is how a request too expensive to serve synchronously still
533
+ gets served: it is computed once. `artifactKey` builds the canonical key, identifying the source by
534
+ the hash of its bytes so the same picture from two places is one artifact:
535
+
536
+ ```ts
537
+ import { artifactKey, transform } from '@gmitch215/tinyimg';
538
+
539
+ const key = await artifactKey(source, options);
540
+ const hit = await caches.default.match(key);
541
+
542
+ if (hit) return hit;
543
+
544
+ const out = await transform(tinyimg, source, options);
545
+ const response = out.response({ 'cache-control': 'public, max-age=31536000, immutable' });
546
+
547
+ ctx.waitUntil(caches.default.put(key, response.clone()));
548
+ return response;
549
+ ```
550
+
551
+ ## Work Counters
552
+
553
+ Every reduction in this library is a claim that some work does not happen, and a label is not
554
+ evidence. `measure` returns what the module did:
555
+
556
+ ```ts
557
+ const { work } = await tinyimg.measure(() => transform(tinyimg, source, { width: 200 }));
558
+
559
+ work.samplesPerTransform; // 1 at an eighth scale, 4 at a quarter, 64 at full
560
+ work.decodedSamples / work.sourceSamples; // what the reduction was worth
561
+ work.filtered; // macroblocks a region did not skip
562
+ ```
563
+
564
+ `samplesPerTransform` is the one to read for a scaled decode: a decode transforming whole blocks and
565
+ averaging them away reads 64 whatever its name is.
566
+
567
+ ## Blobs
568
+
569
+ Three kinds of data load at runtime rather than shipping in the module: fonts, ICC profiles and
570
+ detection cascades. `loadBlob` takes bytes from anywhere, so there are three ways to deliver them and
571
+ no code change between them.
572
+
573
+ The prebuilt set is published at `https://cdn.gmitch215.dev/tinyimg/`, with
574
+ `https://cdn.gmitch215.xyz/tinyimg/` and `https://cdn.gmitch215.blog/tinyimg/` as mirrors.
575
+ `blobs.json` sits at that prefix and each entry's `path` resolves against it, so one URL is enough to
576
+ find the rest. A plain `fetch` needs no binding and costs one subrequest:
577
+
578
+ ```ts
579
+ const font = await fetch('https://cdn.gmitch215.dev/tinyimg/fonts/DejaVuSans.ttf');
580
+ await tinyimg.loadBlob('font', 'sans', await font.arrayBuffer());
581
+ ```
582
+
583
+ A binding costs the same one subrequest and keeps the bytes on your own account. `BLOBS` below is an
584
+ R2 bucket you have uploaded `blobs/` to, and an Assets binding works the same way:
585
+
586
+ ```ts
587
+ const cascade = await env.BLOBS.get('cascades/lbp-frontalface.bin');
588
+ if (cascade) await tinyimg.loadBlob('cascade', 'frontal', cascade.body);
589
+ ```
590
+
591
+ ```jsonc
592
+ {
593
+ "r2_buckets": [{ "binding": "BLOBS", "bucket_name": "tinyimg-blobs" }]
594
+ }
595
+ ```
596
+
597
+ Or bundle one as a wrangler Data module, which costs bundle bytes and no latency:
598
+
599
+ ```ts
600
+ import cascade from './lbp-frontalface.bin';
601
+ await tinyimg.loadBlob('cascade', 'frontal', cascade);
602
+ ```
603
+
604
+ Eight blobs can be resident at once. `freeBlob` releases one and `freeBlobs` releases all. A cascade
605
+ is parsed at load, so a bad one fails at startup instead of becoming a search that finds nothing.
606
+
607
+ ## Error Handling
608
+
609
+ Every failure carries the module's own error code and its stable name, so a caller branches on the
610
+ code rather than on a message.
611
+
612
+ ```ts
613
+ import { TinyImgBlobError, TinyImgFormatError } from '@gmitch215/tinyimg';
614
+
615
+ try {
616
+ return (await transform(tinyimg, source, { width: 400 })).response();
617
+ } catch (error) {
618
+ if (error instanceof TinyImgFormatError) return new Response('unsupported image', { status: 415 });
619
+ if (error instanceof TinyImgBlobError) return new Response('font not loaded', { status: 500 });
620
+
621
+ throw error;
622
+ }
623
+ ```
624
+
625
+ | Class | Covers |
626
+ | ---------------------- | ----------------------------------------------------------------------- |
627
+ | `TinyImgArgumentError` | a null argument, a value out of range, a rectangle outside the image |
628
+ | `TinyImgFormatError` | an unrecognized format, or a variant this build cannot read |
629
+ | `TinyImgDataError` | a malformed, truncated or inconsistent bitstream |
630
+ | `TinyImgMemoryError` | the allocator refused a request that was within budget |
631
+ | `TinyImgTooLargeError` | the image is past the budget, so a smaller decode is the remedy |
632
+ | `TinyImgBlobError` | a font, profile or cascade nobody loaded |
633
+ | `TinyImgPlanError` | the plan is full, or holds an operation it cannot combine |
634
+ | `TinyImgLoadError` | the module is not tinyimg, or its ABI is one this wrapper does not know |
635
+
636
+ ## Size
637
+
638
+ Small size is the primary goal, ahead of speed and then memory. The published package is 138.7 kB,
639
+ of which the wasm module is 90.4 kB gzipped and the wrapper is 7.8 kB. `bun run size` prints the
640
+ breakdown.
641
+
642
+ Every codec can be compiled out, so a caller who only handles PNG can link a module with nothing
643
+ else in it. `--gc-sections` then drops the implementation along with its registry entry.
644
+
645
+ ```sh
646
+ cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=cmake/wasm32.cmake \
647
+ -DCMAKE_C_FLAGS="-DTINYIMG_NO_WEBP -DTINYIMG_NO_TIFF -DTINYIMG_NO_GIF"
648
+ cmake --build build
649
+ ```
650
+
651
+ That build is 65.4 kB gzipped against 90.4, and `tinyimg.features` reports the three as absent, so a
652
+ caller can check before offering a format rather than calling and handling a failure.
653
+
654
+ Build it into its own directory and `bin/tinyimg.wasm` is still where it lands, because that path is
655
+ absolute in `CMakeLists.txt`. Run `bun run build:wasm` afterwards to put the full module back.
656
+
657
+ ## Platform Limits
658
+
659
+ - **The module must arrive compiled.** workerd refuses `WebAssembly.Module(bytes)` with "Wasm code
660
+ generation disallowed by embedder". Import the `.wasm` file; `loadBytes` is for node, bun and the
661
+ browser and throws on Workers.
662
+ - **The Workers Free plan allows 10 ms of CPU per request**, which is not configurable and is below
663
+ the cost of most transformations; see [Speed](#speed). Workers Paid defaults to 30 seconds and goes
664
+ to 5 minutes with `limits.cpu_ms`. A Worker over the limit returns error 1102.
665
+ - **32 MiB per decoded image**, inside 64 MiB of linear memory, with a 16 megapixel ceiling on top
666
+ that only grayscale reaches. The byte cap is the one that binds, because an allocation is bytes: it
667
+ allows 11.2 megapixels of RGB and 8.4 of RGBA. Past either, the library reports
668
+ `TinyImgTooLargeError`, whose remedy is a smaller decode rather than more memory. A larger source
669
+ is still usable; that is what a scaled or region decode is for, and the planner reaches for one on
670
+ its own.
671
+ - **Progressive JPEG cannot stream a region.** Successive approximation needs the whole coefficient
672
+ plane before any pixel is final, so a region request on a progressive file decodes the plane and
673
+ then crops. `probe` reports `progressive` so a caller can tell in advance.
674
+ - **One module owns one linear memory.** Load more than one for isolation between concurrent
675
+ requests; sharing one is normally what you want, and the allocator returns every buffer at the end
676
+ of a call.
677
+ - **Animation is first-frame only.** GIF and WebP animations decode their first frame, and `probe`
678
+ reports the frame count. A request with nothing to do passes the source through whole, so serving
679
+ an animation unchanged keeps every frame; a request that has real work sets `flattened` on the
680
+ result, because there is no animated encoder to write the rest back. Transforming all 57 frames of
681
+ an 800x600 GIF to a 200 px thumbnail measures 335 ms, which is why the first frame is the default
682
+ rather than a limitation waiting to be lifted.
683
+ - **A cascade is scale-dependent.** No single search setting finds every face: a frontal face is
684
+ found at a moderate reduction and lost at full resolution, and a side-facing one can be the
685
+ reverse. `detectFaces` picks a default that suits frontal faces; the C entry point
686
+ `tiny_image_detect_faces_ex` searches exactly what it is told.
687
+ - **`-msimd128` currently buys nothing.** No code path uses SIMD intrinsics, so the flag only
688
+ enables an autovectorizer that `-Oz` disables. Measured at 0.97-1.02x on three operations.
689
+
690
+ ## Out of Scope
691
+
692
+ - **AVIF and HEIF decode and encode.** An AV1 intra decoder is 8-10k lines against 8-10k for the rest
693
+ of the library, decode runs 4-12x the JPEG path per pixel, and encode needs a rate-distortion
694
+ search that costs seconds per image in wasm. The container parse ships so `probe` answers. Codecs
695
+ register through a table, so a second module can carry AV1 for callers who ask.
696
+ - **JPEG XL.** No browser ships it by default.
697
+ - **Animation editing.** Composing or re-timing frames is a different product.
698
+ - **LUT-based ICC profiles.** Rejected with a specific error rather than approximated.
699
+ - **AI-backed effects.** No model, and a Worker is the wrong place for one.
700
+
701
+ ## API Reference
702
+
703
+ Generated documentation lives at [tinyimg.gmitch215.dev](https://tinyimg.gmitch215.dev): the
704
+ TypeScript surface under `/typedoc`, and the C headers under `/doxygen`.
705
+
706
+ ## Contributing
707
+
708
+ `CLAUDE.md` carries the build and test commands, the codec contract, the planner contract for adding
709
+ an operation, and the conventions. `TECHNICAL_REPORT.md` carries the measurements and the approaches
710
+ they refuted.
711
+
712
+ ```sh
713
+ bun install
714
+ bun run build
715
+ bun run test
716
+ ```
717
+
718
+ ## License
719
+
720
+ MIT