@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/LICENSE +21 -0
- package/README.md +720 -0
- package/dist/image.d.ts +263 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +391 -0
- package/dist/image.js.map +1 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/tinyimg.wasm +0 -0
- package/dist/transform.d.ts +135 -0
- package/dist/transform.d.ts.map +1 -0
- package/dist/transform.js +220 -0
- package/dist/transform.js.map +1 -0
- package/dist/types.d.ts +345 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +132 -0
- package/dist/types.js.map +1 -0
- package/dist/wasm.d.ts +367 -0
- package/dist/wasm.d.ts.map +1 -0
- package/dist/wasm.js +283 -0
- package/dist/wasm.js.map +1 -0
- package/package.json +106 -0
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
|