@gmitch215/tinyimg 1.0.0 → 1.1.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # 🏞️ tinyimg
2
2
 
3
- > Lightweight & fast image processing library for Cloudflare Workers
3
+ > Lightweight & fast image processing library for WebAssembly
4
4
 
5
5
  tinyimg decodes, transforms, draws on and re-encodes images inside a Worker, with no binding, no
6
6
  subrequest and no per-transformation charge. It is freestanding C compiled to one wasm32 module,
@@ -23,6 +23,10 @@ own CPU budget.
23
23
  - [Drawing](#drawing)
24
24
  - [Effects](#effects)
25
25
  - [Text](#text)
26
+ - [Setting a Run](#setting-a-run)
27
+ - [Mixed Styles](#mixed-styles)
28
+ - [What the Face Says](#what-the-face-says)
29
+ - [Open Graph Cards](#open-graph-cards)
26
30
  - [Face Detection](#face-detection)
27
31
  - [Color Management](#color-management)
28
32
  - [Reading the dB Figures](#reading-the-db-figures)
@@ -48,8 +52,9 @@ own CPU budget.
48
52
  what to decode before decoding it; see [The Planner](#the-planner).
49
53
  - **A drawing surface.** Shapes, polygons, gradients, compositing with blend modes, and a display
50
54
  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.
55
+ - **Works off a bare instantiate.** A Latin face, four ICC profiles and two face cascades are
56
+ compiled in, so text, color conversion and face detection need no blob and no subrequest. A wider
57
+ glyph set or a cascade of your own loads at runtime.
53
58
  - **TypeScript-first.** The types are the contract and the documentation.
54
59
 
55
60
  ## Compared With Cloudflare Images
@@ -58,54 +63,81 @@ Both do the same job in different places. Images Transformations runs on Cloudfl
58
63
  bills per transformation. tinyimg runs in your Worker's isolate and bills as CPU time, which on the
59
64
  Workers Paid plan is a resource the $5 subscription already includes 30 million milliseconds of.
60
65
 
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 |
66
+ | | Cloudflare Images | tinyimg |
67
+ | --------------------- | --------------------------------------- | ----------------------------------- |
68
+ | Charge per transform | $0.50 / 1,000 unique, 5,000 free / mo | none |
69
+ | What you pay instead | nothing else | Worker CPU time |
70
+ | Billing granularity | unique source and flags, once per month | every invocation that is not cached |
71
+ | Subrequest or binding | required | none |
72
+ | AVIF output | yes | yes |
73
+ | Runs on Workers Free | 5,000 transforms / mo | see [Speed](#speed) |
74
+ | Raw pixel access | no | yes |
75
+ | Shapes and gradients | no | yes |
76
+ | Text measurement | no | yes |
77
+ | Text wrapping | no | yes |
78
+ | Text stroke or shadow | no | yes |
79
+ | Mixed styles in a run | no | yes |
80
+ | Ligatures and GPOS | no | yes |
81
+ | Variable font axes | no | yes |
82
+
83
+ Images renders text as of 2026: `.text(content, options)` takes a font by URL (TrueType, OpenType
84
+ or WOFF, up to 20 MB), a colour and a size, up to 1,000 characters and 4096 x 4096 pixels, positioned
85
+ through the `draw` array with Porter-Duff compositing. The row that used to read "one string, one
86
+ font, one size" understated it.
87
+
88
+ What it does not do is give the caller any measurements back, so nothing can be positioned relative
89
+ to the text. That is the difference the text rows above describe: wrapping a caption to a box,
90
+ centring a line inside a plate, or fitting a title to a width all need the metrics first, and
91
+ `measureText` is the entry point Images has no equivalent of. The rest follows from having a layout
92
+ rather than a string: an outline and a shadow so a headline survives being drawn over a photograph,
93
+ several styles on one baseline, and one variable file serving every weight.
72
94
 
73
95
  ### Cost
74
96
 
75
97
  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:
98
+ its own compute multiplied by that. Four routes to the same 800 px page:
77
99
 
78
100
  | Route | `effort` | Compute | Per million | Within the included CPU |
79
101
  | ----------- | -------- | ------: | ----------: | ----------------------: |
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 |
102
+ | 800 px JPEG | either | 16.7 ms | $0.33 | 1,796,000 |
103
+ | 800 px WebP | fast | 22.4 ms | $0.45 | 1,339,000 |
104
+ | 800 px WebP | fancy | 25.5 ms | $0.51 | 1,176,000 |
105
+ | 800 px AVIF | either | 45 ms | $0.90 | 666,000 |
83
106
 
84
107
  Against **$500 per million** for Images at $0.50 per 1,000. The absolute figures are small whichever
85
108
  route you take. What the spread buys is CPU headroom, and headroom is what the Free plan rations.
109
+ AVIF costs the most compute and produces the smallest file: 17,860 bytes against WebP's 20,572 and
110
+ JPEG's 21,561 on the same 800 px request.
86
111
 
87
112
  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.
113
+ served, so a small set of derivatives under heavy traffic is already cheap there. Since July 2026
114
+ the Images binding bills the same way rather than per call, and `.info()` is not billed at all.
115
+ tinyimg charges for every invocation that runs, so put
116
+ [Workers Cache](https://developers.cloudflare.com/workers/cache/) in front of it and a hit costs no
117
+ CPU at all.
118
+
119
+ The split follows from that. Images is cheaper for a fixed set of derivatives you serve repeatedly.
120
+ tinyimg is cheaper when the derivatives are unbounded, and the clearest case is text that varies per
121
+ request: a social card carrying a page title is a unique transformation per page, so it bills every
122
+ page and never reuses one.
92
123
 
93
124
  ### Speed
94
125
 
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.
126
+ Fastest of 15 runs in bun on darwin-arm64 over `sf-24.jpg` at 1835x1032. The minimum rather than the
127
+ median, because a busy machine can only move a timing upward. This is not Worker CPU time on
128
+ Cloudflare's hardware, so profile your own images.
97
129
 
98
130
  | Request | `effort: 'fancy'` | `effort: 'fast'` |
99
131
  | ---------------------- | ----------------: | ---------------: |
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 |
132
+ | 200 px cover, JPEG q80 | 5.1 ms | 5.0 ms |
133
+ | 400 px cover, JPEG q80 | 9.1 ms | 9.2 ms |
134
+ | 800 px cover, JPEG q80 | 16.7 ms | 16.8 ms |
135
+ | 800 px cover, WebP q80 | 25.5 ms | 22.4 ms |
136
+ | 800 px cover, AVIF q80 | 45 ms | 45 ms |
106
137
 
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.
138
+ `effort` moves WebP and leaves JPEG and AVIF output flat, because the WebP encoder has a 4x4
139
+ prediction search to bound and neither of the others has an equivalent. A PNG output is the other
140
+ one it moves; see [Budget and Effort](#budget-and-effort).
109
141
 
110
142
  JPEG's own lever is on the decode side, and a thumbnail cannot show it. Replicating chroma instead
111
143
  of filtering it is worth 1.11x to 1.25x on a full decode, but the saving scales with output samples
@@ -118,32 +150,53 @@ under 0.1 ms; neither depends on effort.
118
150
 
119
151
  The Workers Free plan allows 10 ms of CPU per request and does not let a caller pay for more, so a
120
152
  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.
153
+ `limits.cpu_ms`, and nothing above comes close to it. Every millisecond below is measured on
154
+ darwin-arm64; Workers is 1.9x slower at the median and up to 4.7x on the cheapest requests, which are
155
+ the ones nearest the limit, so scale before comparing.
122
156
 
123
157
  **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.
158
+ the same 200 px thumbnail from the same eighth-scale decode. The spread is the entropy decode, which
159
+ no output size can reduce.
160
+
161
+ | Source | Megapixels | Compressed | 200 px wide, JPEG |
162
+ | --------- | ---------: | ---------: | ----------------: |
163
+ | 250x187 | 0.05 | 23 KB | 2.2 ms |
164
+ | 2272x3000 | 6.82 | 215 KB | 8.0 ms |
165
+ | 2308x3000 | 6.92 | 435 KB | 12.1 ms |
166
+ | 1920x1280 | 2.46 | 828 KB | 15.4 ms |
167
+ | 3600x2700 | 9.72 | 2721 KB | 53 ms |
168
+
169
+ **Compressed bytes predict it and megapixels do not.** The second and fourth rows make the point on
170
+ their own: 2272x3000 carries 2.8x the pixels of 1920x1280, a quarter of the bytes, and finishes in
171
+ half the time. Across twelve JPEGs, milliseconds per megapixel spread 7.1x while milliseconds per
172
+ megabyte spread 1.95x at this scale. The practical rule for a 200 px thumbnail is roughly **480 KB of
173
+ compressed source**, whatever its dimensions.
174
+
175
+ The estimate `decide()` returns still prices against source samples and is wrong by up to 12.8x for
176
+ that reason, so treat `budgetMs` as a coarse guard rather than a promise. Past the ceiling the
177
+ artifact has to be computed once and cached; see [Caching](#caching).
178
+
179
+ A progressive JPEG is the exception worth knowing about. At an eighth scale the transform reads the
180
+ DC term alone, so the scans that carry only high-frequency detail are stepped over rather than
181
+ decoded: `road.jpg` at 160 px goes from 28.5 ms to **6.5 ms**, byte-identical, which moves that
182
+ request from outside the Free budget to inside it.
183
+
184
+ JPEG and PNG decode are slightly ahead of libjpeg and libpng, at 1.06x-1.09x and 1.03x-1.08x; WebP
185
+ decode is 0.68x-0.72x behind libwebp. JPEG and WebP encode are ahead at 3.8x-4.0x and 1.31x-1.38x.
186
+ PNG encode is 12x behind, and that row is an operating-point difference as much as a speed one, since
187
+ it produces a file 47% smaller; `compression: 'fast'` closes most of it at a stated size cost.
139
188
  [`TECHNICAL_REPORT.md`](https://github.com/gmitch215/tinyimg/blob/master/TECHNICAL_REPORT.md) has
140
189
  the per-stage profile and the comparison against `@jsquash`.
141
190
 
142
191
  ### Gaps
143
192
 
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).
193
+ - **AVIF encode has no partition search.** Decode is complete and matches `dav1d` exactly. The
194
+ encoder uses fixed 8x8 blocks and picks the luma mode from four candidates by absolute
195
+ difference, so its files run 1.1x to 1.3x libaom's size at matched quality. A partition search is
196
+ where most of AV1's compression lives.
197
+ - **No 10 or 12 bit AVIF.** The reconstruction is 8 bits a sample end to end, so an HDR AVIF is
198
+ refused rather than truncated. 4:2:2 is refused too, for a reason in the format: the
199
+ specification's own subsampled-size table has no answer for a tall block on such a plane.
147
200
  - **No animation.** Both decode a first frame; neither re-times or composes one.
148
201
  - **You own the caching.** Images caches derivatives at the edge as a product feature. tinyimg gives
149
202
  you the key and leaves the cache to you; see [Caching](#caching).
@@ -155,8 +208,10 @@ compositing, and a rasterized text string. Past that:
155
208
 
156
209
  - **A drawing surface.** Lines, rectangles, rounded rectangles, circles, ellipses, polygons with both
157
210
  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.
211
+ - **Text as layout.** Wrapping, alignment, justification, vertical alignment, ellipsis, per-line
212
+ metrics, outlines, shadows, mixed styles on one baseline, fallback chains, ligatures, GPOS kerning
213
+ and variable font axes, against Images' single styled string. `og` renders a whole Open Graph card
214
+ in one call.
160
215
  - **Raw pixels.** `decode` and `Image.pixels` hand back samples, so anything not in the operation set
161
216
  is still reachable.
162
217
  - **Effects and warps.** Sobel, emboss, morphology, median, dither, halftone, duotone, split-tone,
@@ -243,12 +298,18 @@ carry `.bytes()`, `.blob()`, `.response()` and `.dataUrl()`.
243
298
  | `format` | `ImageFormat` | Container to encode as. Defaults to the source's own. |
244
299
  | `quality` | `number` | 1 through 100, for a lossy format. |
245
300
  | `lossless`, `progressive` | `boolean` | Where the format has the mode. |
246
- | `metadata` | `'keep' \| 'none'` | Whether EXIF survives the encode. Defaults to `keep`. |
301
+ | `metadata` | `'keep' \| 'none'` | Accepted, but a transformation writes no EXIF either way. |
247
302
 
248
303
  The options apply in a fixed order, exported as `TRANSFORM_ORDER`: crop, resize or fit, rotate, flip,
249
304
  the color adjustments, blur, sharpen, trim. An option object has no order of its own, so two callers
250
305
  writing the same keys get the same image.
251
306
 
307
+ A JPEG with an EXIF orientation is turned upright before anything else runs, so `width`, `height`,
308
+ `crop` and `fit` all read the image as displayed. A phone photo stored landscape and tagged 90
309
+ degrees comes out portrait, and a request for 400 wide is a portrait 400 wide. `probe` reports the
310
+ tag as `orientation`, and the chainable API applies it with `Image.autoOrient()`, which has to be
311
+ the first operation. Only JPEG reports it; every other format reports 1.
312
+
252
313
  ### Fit Modes
253
314
 
254
315
  | Mode | Absorbs the mismatch by | Scale may |
@@ -331,20 +392,34 @@ On that chain the planner is worth 3.63x against running the same operations one
331
392
 
332
393
  ## Codec Support
333
394
 
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 |
395
+ | Format | Decode | Encode |
396
+ | ------ | ------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
397
+ | PNG | 8 and 16 bit, gray/palette/truecolor with and without alpha, Adam7 | hash-chain LZ77, static and dynamic Huffman, the five filters |
398
+ | 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 |
399
+ | WebP | VP8 lossy and VP8L lossless | both modes |
400
+ | GIF | LZW, local and global palettes, interlace, transparency | median-cut palette with Floyd-Steinberg dithering |
401
+ | TIFF | strips, uncompressed / PackBits / LZW / Deflate, both byte orders | uncompressed and PackBits |
402
+ | BMP | uncompressed and RLE8 | uncompressed |
403
+ | AVIF | AV1 intra: every transform, all 13 intra modes, deblock and CDEF | fixed 8x8 partitions, no rate-distortion search |
404
+ | HEIF | container only; `probe` answers and decode reports a specific error | no |
343
405
 
344
406
  JPEG carries true DCT-domain scaled decode at 1/2, 1/4 and 1/8, and region decode that Huffman-scans
345
407
  past blocks outside the box without transforming them. PNG, GIF, TIFF and BMP row-skip and
346
408
  column-skip.
347
409
 
410
+ PNG and deflate-compressed TIFF take a `compression` option, which is a third axis beside quality and
411
+ effort: for a lossless format the whole output is the compressor's, so quality says nothing about it.
412
+
413
+ | `compression` | What it does |
414
+ | ------------- | ------------------------------------------------------------------------------------------- |
415
+ | `auto` | Reads quality, which is what every caller got before the option existed |
416
+ | `none` | Entropy codes without searching for matches, which beats `fast` on filtered photograph rows |
417
+ | `fast` | One hash chain probe per position |
418
+ | `default` | A bounded chain walk with lazy matching |
419
+ | `best` | A long chain walk |
420
+
421
+ On a 96 px logo `best` is 664 bytes against 835 at `default`, for 4.3x the CPU.
422
+
348
423
  `probe` reads headers only, for every format including the ones the library cannot decode:
349
424
 
350
425
  ```ts
@@ -390,14 +465,132 @@ TrueType and OpenType with `glyf` outlines, plus PSF and BDF bitmap faces, dispa
390
465
  bytes. Quadratic flattening and a scanline fill with four vertical subsamples and exact horizontal
391
466
  coverage.
392
467
 
468
+ A Latin face is compiled into the module, so drawing text needs no setup:
469
+
393
470
  ```ts
394
- const font = await env.BLOBS.get('fonts/DejaVuSans.ttf');
395
- if (font) await tinyimg.loadBlob('font', 'sans', font.body);
471
+ using font = Font.load(tinyimg);
396
472
  ```
397
473
 
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.
474
+ It covers ASCII and eight accented letters in 21 kB. A wider glyph set or a different face is a blob
475
+ under a name of your own, and a fallback chain puts one behind the other rather than replacing it:
476
+
477
+ ```ts
478
+ const noto = await fetch('https://example.com/NotoSansSC-Regular.ttf');
479
+ await tinyimg.loadBlob('font', 'cjk', await noto.arrayBuffer());
480
+ ```
481
+
482
+ `tiny_image_draw_text`, `tiny_image_draw_text_in` and `tiny_text_measure` share one layout walk, so
483
+ measuring and then drawing at the measured position lands where the measurement said.
484
+
485
+ ### Setting a Run
486
+
487
+ `TinyTextStyle` carries the size, tracking, line height, kerning, an outline and a shadow.
488
+ `TinyTextBox` carries the rectangle: a wrap width, a height, horizontal alignment including justify,
489
+ vertical alignment, and whether text the box cannot hold is clipped or ends in an ellipsis.
490
+
491
+ A run with an outline or a shadow is drawn in three passes, every shadow then every outline then
492
+ every fill, so a letter's outline never prints over its neighbor's fill. The outline is a dilation of
493
+ the glyph's coverage rather than an offset of its curve, which rounds a sharp interior corner that a
494
+ true offset would extend to a point; at the one to four pixels a card uses the difference is not
495
+ visible.
496
+
497
+ `tiny_text_lines` reports the layout one line at a time: the byte range, the width, where the line
498
+ sits once aligned, its baseline, and whether the box clipped it or an ellipsis ended it. It is what
499
+ tells a caller a headline was truncated before the card is served.
500
+
501
+ ### Mixed Styles
502
+
503
+ `tiny_image_draw_text_runs` takes up to sixteen spans, each with its own face, size, color, outline
504
+ and shadow, and flows them as one run: a wrap can land in the middle of a span, and a line that mixes
505
+ sizes sits on one baseline, which is the largest ascent on that line. Kerning does not cross a span
506
+ boundary, because a kern pair belongs to one face and the glyphs either side may not.
507
+
508
+ ### What the Face Says
509
+
510
+ | Table | Read for |
511
+ | ------ | ----------------------------------------------------------------------- |
512
+ | `kern` | Legacy pair kerning |
513
+ | `GPOS` | Pair kerning under the `kern` feature, formats 1 and 2 |
514
+ | `GSUB` | Ligatures under the `liga` feature, longest match first |
515
+ | `fvar` | Variation axes, their ranges and their defaults |
516
+ | `avar` | Axis remapping, so the midpoint of an axis is where the designer put it |
517
+ | `gvar` | Outline and advance deltas, with regions and inferred points |
518
+
519
+ Modern faces ship `GPOS` and often no `kern` at all, so a reader that only knew the legacy table
520
+ would read most fonts as unkerned. `GPOS` is consulted first and the legacy table is the fallback.
521
+
522
+ One variable file serves every weight:
523
+
524
+ ```ts
525
+ tiny_font_set_axis(font, TINYIMG_AXIS_WEIGHT, 700);
526
+ ```
527
+
528
+ The outlines are interpolated from the face's own deltas, and so are the advances, which come from
529
+ the same deltas' phantom points. Points no region names are inferred from their neighbors along the
530
+ contour, which is what keeps an outline whole rather than torn.
531
+
532
+ ### Open Graph Cards
533
+
534
+ `og` renders one:
535
+
536
+ ```ts
537
+ const card = await og(tinyimg, {
538
+ title: 'How a planner made the decode cheap',
539
+ subtitle: 'Deciding what to decode before decoding it',
540
+ footer: 'tinyimg.gmitch215.dev',
541
+ background: '#0b1020'
542
+ });
543
+
544
+ return card.response({ 'cache-control': 'public, max-age=86400' });
545
+ ```
546
+
547
+ 1200x630 by default, with the headline wrapped, truncated with an ellipsis when it does not fit, and
548
+ a shadow under the text. `image` covers the card with a photograph, fitted by the planner, so a
549
+ 4000 px source is never decoded at 4000 px to end up 1200 wide. The result reports `titleTruncated`
550
+ and `missingGlyphs`, which are the two ways a card goes out wrong.
551
+
552
+ A Worker that serves cards for its own pages, cached so each one is rendered once:
553
+
554
+ ```ts
555
+ import wasm from '@gmitch215/tinyimg/tinyimg.wasm';
556
+ import { og, TinyImgModule } from '@gmitch215/tinyimg';
557
+
558
+ const tinyimg = TinyImgModule.load(wasm);
559
+
560
+ export default {
561
+ async fetch(request: Request, env: unknown, ctx: ExecutionContext): Promise<Response> {
562
+ const url = new URL(request.url);
563
+ const cache = caches.default;
564
+
565
+ const hit = await cache.match(request);
566
+ if (hit) return hit;
567
+
568
+ const card = await og(tinyimg, {
569
+ title: url.searchParams.get('title') ?? 'Untitled',
570
+ subtitle: url.searchParams.get('subtitle') ?? undefined,
571
+ footer: url.hostname,
572
+ background: '#0b1020',
573
+ format: 'png'
574
+ });
575
+
576
+ if (card.missingGlyphs > 0) {
577
+ // the compiled-in face is a Latin subset; load a wider one for anything else
578
+ console.warn(`${card.missingGlyphs} codepoints had no glyph`);
579
+ }
580
+
581
+ const response = card.response({
582
+ 'cache-control': 'public, max-age=604800',
583
+ 'x-title-truncated': String(card.titleTruncated)
584
+ });
585
+
586
+ ctx.waitUntil(cache.put(request, response.clone()));
587
+
588
+ return response;
589
+ }
590
+ };
591
+ ```
592
+
593
+ Nothing is loaded and no subrequest is made, so the whole card is CPU and one cache write.
401
594
 
402
595
  100 characters at 16 px cost 0.156 ms.
403
596
 
@@ -406,28 +599,23 @@ measurement said. Kern pairs are applied when the face carries a `kern` table.
406
599
  An LBP cascade over an integral image, multi-scale, with union-find grouping. The cascades are
407
600
  OpenCV's `lbpcascade_frontalface_improved.xml` and `lbpcascade_profileface.xml`.
408
601
 
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:
602
+ Both cascades are compiled into the module, so detection needs no setup:
413
603
 
414
604
  ```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
605
  const faces = await tinyimg.detectFaces(source);
422
606
  // [{ x, y, width, height, neighbors }]
423
607
  ```
424
608
 
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.
609
+ The module carries no XML parser, so a cascade is a packed form that `scripts/fixtures.ts` builds
610
+ from the OpenCV source: 7.4 kB for the frontal cascade against 54 kB of XML. `loadBlob` rejects one
611
+ still in XML, and `bun run blobs` rebuilds the packed pair if you would rather train your own.
428
612
 
429
- Feeds `gravity: 'face'`. With no cascade loaded, `detectFaces` reports a `TinyImgBlobError` and
430
- `gravity: 'face'` falls back to `auto`.
613
+ Loading a cascade of your own replaces both builtins rather than joining them, which is what a host
614
+ substituting a default expects. With none loaded, the two that ship both run and their results are
615
+ grouped, so a frontal and a profile cascade find both kinds of face and a face that fires both is one
616
+ box. Detections match OpenCV's own detector within a pixel on identical pixels.
617
+
618
+ Feeds `gravity: 'face'`, which therefore works without loading anything.
431
619
 
432
620
  ## Color Management
433
621
 
@@ -474,35 +662,56 @@ against the source's header first, and hands the original bytes back when there
474
662
  look like; effort says how hard to work to get there. It applies to the decode, the resize and the
475
663
  encode, and each part gives up something measured:
476
664
 
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.
665
+ | Stage | What `fast` does | Speed | Quality |
666
+ | ------ | -------------------------------------------- | -------------- | --------------- |
667
+ | Decode | WebP skips deblocking | 1.53x | 46.8 dB |
668
+ | Decode | WebP reduces in the plane domain | 2.05x to 3.35x | 38.9 dB |
669
+ | Decode | JPEG replicates chroma instead of filtering | 1.11x to 1.25x | 43.6 to 59.5 dB |
670
+ | Resize | Bilinear instead of Catmull-Rom, enlarging | 3.2x | 45.7 dB |
671
+ | Encode | WebP bounds the 4x4 prediction search | 1.18x to 1.45x | within 0.05 dB |
672
+ | Encode | PNG compresses one candidate stream, not two | 1.59x to 2.08x | see below |
673
+
674
+ The plane-domain reduction reaches a scaled WebP request only, and its speed figure includes the
675
+ deblocking skip on the same request. It averages the luma and chroma planes over each output pixel
676
+ and converts once, where the default converts the frame and averages the result, so it also never
677
+ allocates the full frame of RGBA.
678
+
679
+ The PNG row is the one whose cost depends on what you are encoding. The encoder compresses an
680
+ adaptively filtered stream and an unfiltered one and keeps the smaller; `fast` compresses only the
681
+ adaptive one. On a photograph the adaptive stream wins anyway, so the output is byte-identical and
682
+ the speed is free: `sf-24.jpg` at 800 px measured 142 ms against 68. On flat artwork the unfiltered
683
+ stream is the one that would have won, so `fast` pays 7.0% more bytes on `base-mono.gif`, 17.2% on
684
+ `webassembly.png` and 63.1% on a 96x96 logo. Resizing a photograph gets the speed for nothing;
685
+ encoding an icon should stay at `fancy`.
686
+
687
+ **A lossy decoder has more to trade than a lossless one.** A lossless bitstream defines its pixels
688
+ exactly, so every step is needed to produce them and PNG, GIF, TIFF and lossless WebP decode
689
+ identically at either effort. So does a 4:4:4 JPEG, which has no chroma to upsample. A reduction
690
+ keeps its filter too, because an area average is already both the cheapest and the right answer;
691
+ only enlarging has a filter to step down. What the lossless formats do have is an encoder with a
692
+ search to bound, which is the PNG row above.
489
693
 
490
694
  A filter you name is never substituted, and neither is a format. Effort decides what the library
491
695
  left open, not what you asked for.
492
696
 
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.
697
+ End to end, on requests through the whole pipeline. Fastest of 25 interleaved runs, so contention
698
+ moves a row one way only:
699
+
700
+ | Request | `fancy` | `fast` | |
701
+ | ----------------------------- | ------: | -----: | --------- |
702
+ | 200 px, WebP source, WebP out | 15 ms | 7.6 ms | 1.9x |
703
+ | 800 px, JPEG source, PNG out | 103 ms | 57 ms | 1.8x |
704
+ | 800 px, WebP source, WebP out | 44 ms | 36 ms | 1.3x |
705
+ | 800 px, JPEG source, WebP out | 28 ms | 22 ms | 1.1x-1.4x |
706
+ | 800 px, JPEG source, JPEG out | 17 ms | 17 ms | no lever |
707
+ | 800 px, JPEG source, AVIF out | 53 ms | 53 ms | no lever |
708
+
709
+ A WebP source gains most at 200 px, where both the decode and the encode have something to give and
710
+ the decode additionally reduces in the plane domain. The two bottom rows produce byte-identical
711
+ output at either effort, which is how you can tell there is nothing to spend rather than something
712
+ being spent badly: a 4:4:4 JPEG re-encoded as JPEG has no deblocking pass, no chroma to replicate
713
+ and no prediction search, and the AVIF encoder does not read the effort field at all. Timings on
714
+ those rows differ between runs by a few percent in either direction, which is the machine.
506
715
 
507
716
  Encoded size can go either way under `fast`, because the per-subblock choice minimizes prediction
508
717
  error rather than rate: a diagonal mode can win on error and then cost more bits to code. Flat-color
@@ -527,6 +736,37 @@ A format you name is never substituted, however far over budget it is. The estim
527
736
  about 20% on the machine its rates were measured on, so give a budget below the limit rather than at
528
737
  it, and use [`measure`](#work-counters) when you need what a request cost.
529
738
 
739
+ **A budget the container change cannot meet degrades the plan, and the result says what it gave up.**
740
+ Container first, because a cheaper encoder costs bytes and nothing else; then the planner's own
741
+ levers, in the order of what they cost the picture:
742
+
743
+ | Reported as | What it gives up |
744
+ | ------------- | -------------------------------------------------------------------------------------- |
745
+ | `compression` | A lossless output's compressor is turned down, which costs bytes and not fidelity |
746
+ | `effort` | The decode runs fast, and an enlargement left open steps down a filter |
747
+ | `filter` | A filter left open goes to nearest |
748
+ | `scale` | The decoder is asked for a coarser grid than the output needs, so the result is softer |
749
+
750
+ `compression` comes first because on a PNG or TIFF output the encoder costs more than everything the
751
+ planner controls, so softening the picture to pay for the compressor would be the wrong trade. It
752
+ measured 2.8x to 3.7x on a budgeted PNG request. Naming `compression` pins the level and the planner
753
+ degrades instead.
754
+
755
+ ```ts
756
+ const result = await transform(tinyimg, source, { width: 600, budgetMs: 4 });
757
+
758
+ result.degraded; // ['effort'] when the effort tier was enough to fit
759
+ ```
760
+
761
+ The order is not the intuitive one, and a measurement decided it: reducing the decode before the
762
+ filter turns a cheap box reduction into an interpolating enlargement, which on one 600 px request
763
+ measured 22,780 microseconds against 21,801 for no degradation at all. A rung that is not cheaper is
764
+ skipped, so what comes back is never softer for more CPU than the undegraded plan.
765
+
766
+ The output extent never changes. A budget decides how the pixels are made, not how many, and nothing
767
+ is degraded when the estimate already fits. `degraded` is empty on a pass-through, which needs no
768
+ lever at all.
769
+
530
770
  ## Caching
531
771
 
532
772
  A cache hit costs no CPU at all, which is how a request too expensive to serve synchronously still
@@ -566,9 +806,17 @@ averaging them away reads 64 whatever its name is.
566
806
 
567
807
  ## Blobs
568
808
 
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.
809
+ A default of each kind is compiled into the module: a Latin face, the four ICC profiles and both LBP
810
+ cascades, about 45 kB. Text, profile conversion and face detection therefore work off a bare
811
+ instantiate, and `builtinBlobs` lists what is there.
812
+
813
+ A blob is how you replace one or add to it: a wider glyph set, another cascade, a profile of your
814
+ own. `loadBlob` takes bytes from anywhere, so there are three ways to deliver them and no code change
815
+ between them.
816
+
817
+ A name resolves against the loaded blobs first and then the builtins, so `sans` always answers. An
818
+ enumeration is different: one loaded blob of a kind hides every builtin of that kind, which is what
819
+ stops a host that installed its own cascade from also running the two that ship.
572
820
 
573
821
  The prebuilt set is published at `https://cdn.gmitch215.dev/tinyimg/`, with
574
822
  `https://cdn.gmitch215.xyz/tinyimg/` and `https://cdn.gmitch215.blog/tinyimg/` as mirrors.
@@ -601,8 +849,11 @@ import cascade from './lbp-frontalface.bin';
601
849
  await tinyimg.loadBlob('cascade', 'frontal', cascade);
602
850
  ```
603
851
 
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.
852
+ Eight blobs can be resident at once. `freeBlob` releases one and `freeBlobs` releases all; neither
853
+ touches a builtin, which lives in the module's data section. A cascade is parsed at load, so a bad
854
+ one fails at startup instead of becoming a search that finds nothing.
855
+
856
+ The face and the cascades are third-party data. Their notices are in `LICENSES/`.
606
857
 
607
858
  ## Error Handling
608
859
 
@@ -635,21 +886,24 @@ try {
635
886
 
636
887
  ## Size
637
888
 
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.
889
+ The wasm module is 778 kB, of which 45 kB is the data it carries: a Latin face, four ICC profiles and
890
+ two detection cascades. The AV1 codec behind AVIF is 283 kB of it, about half of that in generated
891
+ tables. The wrapper is 52 kB of JavaScript. `bun run size` prints the breakdown per translation unit.
641
892
 
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.
893
+ Speed comes first, then size, then memory. Size still gets measured, because a bigger module costs
894
+ startup time and `tiny` is in the name, but it no longer wins an argument against speed.
895
+
896
+ Every codec can be compiled out, so a caller who only handles PNG can link a module with nothing else
897
+ in it. `--gc-sections` then drops the implementation along with its registry entry.
644
898
 
645
899
  ```sh
646
900
  cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=cmake/wasm32.cmake \
647
- -DCMAKE_C_FLAGS="-DTINYIMG_NO_WEBP -DTINYIMG_NO_TIFF -DTINYIMG_NO_GIF"
901
+ -DCMAKE_C_FLAGS="-DTINYIMG_NO_WEBP -DTINYIMG_NO_TIFF -DTINYIMG_NO_GIF -DTINYIMG_NO_AVIF"
648
902
  cmake --build build
649
903
  ```
650
904
 
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.
905
+ That build is 366 kB, and `tinyimg.features` reports the four as absent, so a caller can check
906
+ before offering a format rather than calling and handling a failure.
653
907
 
654
908
  Build it into its own directory and `bin/tinyimg.wasm` is still where it lands, because that path is
655
909
  absolute in `CMakeLists.txt`. Run `bun run build:wasm` afterwards to put the full module back.
@@ -661,7 +915,11 @@ absolute in `CMakeLists.txt`. Run `bun run build:wasm` afterwards to put the ful
661
915
  browser and throws on Workers.
662
916
  - **The Workers Free plan allows 10 ms of CPU per request**, which is not configurable and is below
663
917
  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.
918
+ to 5 minutes with `limits.cpu_ms`. A Worker over the limit returns error 1102. Enforcement is
919
+ elastic, so an isolated request over the limit may still complete; sustained ones do not, and a
920
+ single test request that passed is not evidence that a request fits.
921
+ - **Response body bytes are not billed as CPU.** A 2.88 MB body measured 99 ms of wall clock and zero
922
+ CPU, so the limit applies to the transformation and not to serving it.
665
923
  - **32 MiB per decoded image**, inside 64 MiB of linear memory, with a 16 megapixel ceiling on top
666
924
  that only grayscale reaches. The byte cap is the one that binds, because an allocation is bytes: it
667
925
  allows 11.2 megapixels of RGB and 8.4 of RGBA. Past either, the library reports
@@ -684,15 +942,17 @@ absolute in `CMakeLists.txt`. Run `bun run build:wasm` afterwards to put the ful
684
942
  found at a moderate reduction and lost at full resolution, and a side-facing one can be the
685
943
  reverse. `detectFaces` picks a default that suits frontal faces; the C entry point
686
944
  `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.
945
+ - **No hand-written SIMD kernels.** The flag is on and the autovectorizer runs, which is worth
946
+ 1.04x to 1.15x now that the build is `-O2` rather than `-Oz`. Nothing uses intrinsics directly, so
947
+ the widest stages are still scalar.
689
948
 
690
949
  ## Out of Scope
691
950
 
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.
951
+ - **HEIF decode and encode.** HEIF shares AVIF's container and carries HEVC rather than AV1. HEVC
952
+ decoders are covered by active patent pools, which is a licensing question rather than a technical
953
+ one and the reason this is declined rather than queued. The container parse ships so `probe`
954
+ answers, and Safari is the only browser that displays HEIF, so it matters as a camera source
955
+ format rather than an output one.
696
956
  - **JPEG XL.** No browser ships it by default.
697
957
  - **Animation editing.** Composing or re-timing frames is a different product.
698
958
  - **LUT-based ICC profiles.** Rejected with a specific error rather than approximated.
@@ -717,4 +977,10 @@ bun run test
717
977
 
718
978
  ## License
719
979
 
720
- MIT
980
+ MIT.
981
+
982
+ The module compiles in two third-party works, and their notices ship with the package in
983
+ `LICENSES/`. The Latin face is a subset of DejaVu Sans, under the Bitstream Vera Fonts license
984
+ (`LICENSES/DejaVu.txt`). The two face cascades are packed from OpenCV's LBP data files, under the
985
+ 3-clause BSD license (`LICENSES/OpenCV-LBP-Cascades.txt`). The four ICC profiles are generated from
986
+ published primaries and transfer curves rather than copied from anyone's files.