@ultimat3/core 5.0.1 → 6.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 +42 -18
- package/package.json +1 -1
- package/src/async-context.ts +77 -0
- package/src/config.ts +26 -12
- package/src/context.ts +13 -8
- package/src/error-codes.ts +1 -0
- package/src/image/canvas.ts +170 -0
- package/src/image/errors.ts +35 -3
- package/src/image/pipeline.ts +91 -70
- package/src/image/png-pixels.ts +183 -0
- package/src/impersonate.ts +8 -5
- package/src/index.ts +4 -9
- package/src/telemetry.ts +7 -4
- package/src/time-zone-name.ts +43 -0
- package/src/image/jpeg-decode.ts +0 -283
- package/src/image/jpeg-encode.ts +0 -463
- package/src/image/jpeg-headers.ts +0 -267
- package/src/image/jpeg-huffman.ts +0 -202
- package/src/image/jpeg-tables.ts +0 -117
- package/src/image/png.ts +0 -433
- package/src/image/resize.ts +0 -320
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
38
38
|
| the sockets this process opened, so a self-request is not egress | `listeners.ts` |
|
|
39
39
|
| `defineService('orgs', …)` → `ctx.orgs`, rebuilt per actor | `service.ts` |
|
|
40
40
|
| the registrar table one same-tier package reaches another through | `registrar.ts` |
|
|
41
|
-
| decode → resize → encode, the one image pipeline | `image/` |
|
|
41
|
+
| decode → resize → encode, the one image pipeline (over `Bun.Image`) | `image/` |
|
|
42
42
|
| `assertNever`, `invariant` | `assert.ts` |
|
|
43
43
|
|
|
44
44
|
## Errors are instructions
|
|
@@ -466,31 +466,55 @@ than in `@ultimat3/time` because `@ultimat3/money` needs it too and tier 1 may n
|
|
|
466
466
|
|
|
467
467
|
```ts
|
|
468
468
|
probeImage(bytes); // { format, width, height, mimeType }
|
|
469
|
-
transformImageBytes(bytes, { width: 640, format: '
|
|
470
|
-
blurDataUrl(bytes);
|
|
469
|
+
await transformImageBytes(bytes, { width: 640, format: 'webp', quality: 80 });
|
|
470
|
+
await blurDataUrl(bytes); // ThumbHash PNG data: URI, the LQIP
|
|
471
471
|
```
|
|
472
472
|
|
|
473
473
|
`storage` variants, `seo` `<picture>` sources and `pwa` icons are the same three steps —
|
|
474
474
|
decode, resize, encode — with different numbers, so there is one implementation and no second
|
|
475
|
-
scaler for an icon to grow a halo in.
|
|
475
|
+
scaler for an icon to grow a halo in. The codecs are **`Bun.Image`** — statically-linked
|
|
476
|
+
libjpeg-turbo / libspng / libwebp with SIMD resize kernels, in the runtime. Still zero
|
|
477
|
+
dependencies: no `sharp`, no native module.
|
|
478
|
+
|
|
479
|
+
Every terminal is `async`, because the pipeline runs on a worker thread. `probeImage` stays
|
|
480
|
+
synchronous: it reads a header and never decodes, which is also why it measures SVG and AVIF that
|
|
481
|
+
no codec here reads.
|
|
476
482
|
|
|
477
483
|
| | |
|
|
478
484
|
|---|---|
|
|
479
|
-
| Decode
|
|
480
|
-
|
|
|
485
|
+
| Decode | PNG, JPEG, WebP, GIF. `canDecode()` publishes the real list |
|
|
486
|
+
| Encode | PNG, JPEG, WebP. `canEncode()` publishes the real list |
|
|
487
|
+
| Probe only | AVIF and SVG — measured from the header so `width`/`height` still inline and CLS stays 0 |
|
|
481
488
|
| Anything else | `X_IMAGE_UNSUPPORTED`, naming the format and pointing at an `ImageTransformDriver` |
|
|
482
|
-
| Ceiling | `MAX_IMAGE_PIXELS` (64MP),
|
|
483
|
-
| Determinism | same bytes + same spec → same output bytes
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
489
|
+
| Ceiling | `MAX_IMAGE_PIXELS` (64MP), passed to the decoder as `maxPixels` and refused from the header **before** a byte is allocated |
|
|
490
|
+
| Determinism | same bytes + same spec → same output bytes, **on every platform** |
|
|
491
|
+
|
|
492
|
+
**AVIF and HEIC are refused everywhere, deliberately.** `Bun.Image` can reach them through an OS
|
|
493
|
+
codec (ImageIO on macOS, WIC on Windows), and this pipeline sets `Bun.Image.backend = 'bun'` on
|
|
494
|
+
every call to forbid exactly that: the static codecs and the Highway geometry kernels are what make
|
|
495
|
+
a laptop and a Linux node produce the same bytes, and `variantKey` is content-addressed. A variant
|
|
496
|
+
that re-encoded differently per platform is a cache that never hits. Producing AVIF means a CDN or
|
|
497
|
+
a custom `ImageTransformDriver`.
|
|
498
|
+
|
|
499
|
+
`transformImageBytes` has two paths and picks by geometry. When the resampled artwork IS the output
|
|
500
|
+
box it is one `Bun.Image` call, source bytes to encoded bytes. When it is not — a letterbox, a
|
|
501
|
+
`padding`, a `cover` crop — the artwork comes back as PNG and `canvas.ts` composites it, because
|
|
502
|
+
`Bun.Image` resamples but has no compositor and the PWA maskable safe zone is a composite.
|
|
503
|
+
`png-pixels.ts` is the raw-pixel seam that hop needs, 8-bit RGBA only; anything else is
|
|
504
|
+
`X_IMAGE_UNSUPPORTED` naming `transformImageBytes`.
|
|
505
|
+
|
|
506
|
+
Adding a format is an entry in `DECODABLE_FORMATS` / `ENCODABLE_FORMATS` and a branch in
|
|
507
|
+
`withFormat` — never a second dispatch. An unencodable `format` is refused from the spec alone,
|
|
508
|
+
before the source is decoded, so a request nothing can write never expands 64 megapixels first.
|
|
509
|
+
|
|
510
|
+
`Bun.Image` rejects with `ERR_IMAGE_*` on `error.code`. `imageFromBunError` is the ONE place that
|
|
511
|
+
is read, mapping it onto `X_IMAGE_UNSUPPORTED` / `X_IMAGE_TOO_LARGE` / `X_IMAGE_DECODE_FAILED`; no
|
|
512
|
+
caller branches on a Bun code.
|
|
513
|
+
|
|
514
|
+
Two files in `image/` are past the 200-line target and neither splits without inventing a seam:
|
|
515
|
+
`probe.ts` is one algorithm per format over header bytes, and `fixtures.ts` is data. The 500-line
|
|
516
|
+
hard ceiling applies to both. Everything else in `image/` is under the target — deleting the
|
|
517
|
+
hand-rolled JPEG and PNG codecs is what put it there.
|
|
494
518
|
|
|
495
519
|
`image/fixtures.ts` is byte-exact output from Pillow and ffmpeg on purpose: a codec that only round
|
|
496
520
|
trips against itself proves nothing. Never regenerate a fixture with our own encoder.
|
package/package.json
CHANGED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Single responsibility: the one lazily-constructed `AsyncLocalStorage` in the framework. Every
|
|
2
|
+
// ambient value core carries — the request context, the active span, the impersonation reason —
|
|
3
|
+
// opens its scope through this seam, so "what happens where there is no async context" has one
|
|
4
|
+
// answer instead of one per module.
|
|
5
|
+
|
|
6
|
+
// `node:async_hooks` is unavoidable and deliberate: nothing else in Bun makes a value ambient
|
|
7
|
+
// across an `await` without threading it through every signature in the framework.
|
|
8
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
9
|
+
import { UltimateError } from './errors';
|
|
10
|
+
|
|
11
|
+
export interface AsyncContext<T> {
|
|
12
|
+
/** The value in flight, or `undefined` — outside a scope, and in a runtime that has none. */
|
|
13
|
+
get(): T | undefined;
|
|
14
|
+
/** Run `fn` with `value` in flight. `X_ASYNC_CONTEXT_UNAVAILABLE` where that is impossible. */
|
|
15
|
+
run<R>(value: T, fn: () => R): R;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The storage is constructed on first `run()`, never at module scope. That is the whole point of
|
|
20
|
+
* this file: a browser bundler stubs `node:async_hooks` to `{}` — Bun's `target: 'browser'` emits
|
|
21
|
+
* `var { AsyncLocalStorage } = (() => ({}))` — so a module-scope `new` threw
|
|
22
|
+
* `TypeError: undefined is not a constructor` at module EVALUATION, and every package that
|
|
23
|
+
* transitively imports core was dead on arrival in a client bundle. `@ultimat3/ui` calls itself a
|
|
24
|
+
* SolidJS design system and could not be put on a client by the only client bundler the framework
|
|
25
|
+
* has, for this reason and no other.
|
|
26
|
+
*
|
|
27
|
+
* The server pays nothing: `getStore()` before any `run()` answers `undefined` whether the storage
|
|
28
|
+
* was ever constructed or not, so deferring the construction changes no observable behaviour.
|
|
29
|
+
*
|
|
30
|
+
* **Reads degrade, writes throw**, and that split is the doctrine rather than a convenience.
|
|
31
|
+
* `get()` answers `undefined` in a browser because that is TRUE — nothing is in flight there, so
|
|
32
|
+
* "am I inside a scope" has a definite no for an answer and does not deserve an exception. It is
|
|
33
|
+
* the same call `@ultimat3/ui`'s `solid()` makes: inert where the capability is genuinely absent,
|
|
34
|
+
* throwing only where a caller asked for something the runtime cannot deliver. `run()` is that
|
|
35
|
+
* second case, so it names itself with a code and a fix rather than leaving a bare `TypeError`
|
|
36
|
+
* from a stack that mentions no file the caller wrote.
|
|
37
|
+
*
|
|
38
|
+
* **A synchronous save/restore fallback is not the answer here**, and this note exists so that it
|
|
39
|
+
* is not re-proposed: a module-level `current` swapped in a `try`/`finally` serves sync code and
|
|
40
|
+
* is silently WRONG across an `await` — two overlapping scopes interleave and the second one's
|
|
41
|
+
* `finally` restores a value the first is still inside. That is the `jobs: { driver: 'redis' }`
|
|
42
|
+
* failure mode this repo already paid for once: accepted, unwarned, and wrong in the dangerous
|
|
43
|
+
* direction. An error a caller can read beats an ambient value that is occasionally somebody
|
|
44
|
+
* else's.
|
|
45
|
+
*
|
|
46
|
+
* `subject` names what could not be opened, and is a `string` by construction — never an
|
|
47
|
+
* `unknown` reaching a `cause:`, which `bun run error-render` refuses.
|
|
48
|
+
*/
|
|
49
|
+
export function asyncContext<T>(subject: string): AsyncContext<T> {
|
|
50
|
+
let storage: AsyncLocalStorage<T> | undefined;
|
|
51
|
+
|
|
52
|
+
function open(): AsyncLocalStorage<T> | undefined {
|
|
53
|
+
if (storage !== undefined) return storage;
|
|
54
|
+
// The stub is an object with no `AsyncLocalStorage` key, so the binding reads `undefined`.
|
|
55
|
+
if (typeof AsyncLocalStorage !== 'function') return undefined;
|
|
56
|
+
storage = new AsyncLocalStorage<T>();
|
|
57
|
+
return storage;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
get(): T | undefined {
|
|
62
|
+
return open()?.getStore();
|
|
63
|
+
},
|
|
64
|
+
run<R>(value: T, fn: () => R): R {
|
|
65
|
+
const store = open();
|
|
66
|
+
if (store === undefined) {
|
|
67
|
+
throw new UltimateError({
|
|
68
|
+
code: 'X_ASYNC_CONTEXT_UNAVAILABLE',
|
|
69
|
+
cause: `${subject} needs AsyncLocalStorage, and node:async_hooks is stubbed to {} in this runtime`,
|
|
70
|
+
fix: `${subject} is server-only — open it in apps/web/server.ts or a route handler, and keep every module that opens one out of the import graph of a client island entry`,
|
|
71
|
+
meta: { subject },
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return store.run(value, fn);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { ConfigInvalidError } from './errors';
|
|
6
6
|
import { ROLES, type Role } from './roles';
|
|
7
|
+
import { isIanaZoneName } from './time-zone-name';
|
|
7
8
|
|
|
8
9
|
export type ThemeMode = 'light' | 'dark' | 'system';
|
|
9
10
|
export type OfflineStrategy = 'precache' | 'runtime' | 'network-only';
|
|
@@ -205,15 +206,6 @@ const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/;
|
|
|
205
206
|
*/
|
|
206
207
|
const CURRENCY_RE = /^[A-Z]{3}$/;
|
|
207
208
|
|
|
208
|
-
function isTimeZone(value: string): boolean {
|
|
209
|
-
try {
|
|
210
|
-
new Intl.DateTimeFormat('en', { timeZone: value });
|
|
211
|
-
return true;
|
|
212
|
-
} catch {
|
|
213
|
-
return false;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
209
|
function isLocale(value: string): boolean {
|
|
218
210
|
try {
|
|
219
211
|
return Intl.getCanonicalLocales(value).length === 1;
|
|
@@ -252,8 +244,22 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
|
|
|
252
244
|
};
|
|
253
245
|
}
|
|
254
246
|
|
|
247
|
+
const BASE_FIX = 'edit app.config.ts to fix the fields named in cause, then run: x verify';
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Appended only when the zone is what failed. Axiom 4: an operator holding `'CET'` needs the
|
|
251
|
+
* spelling to write, and the two refused classes have different remedies — a single-label legacy
|
|
252
|
+
* name swaps mechanically, an abbreviation or an offset has no replacement at all because it names
|
|
253
|
+
* no jurisdiction. Deliberately parallel to `@ultimat3/time`'s `X_TIMEZONE_INVALID` fix, since the
|
|
254
|
+
* two refuse the same strings and an operator may meet either first.
|
|
255
|
+
*/
|
|
256
|
+
const TIMEZONE_FIX =
|
|
257
|
+
"set defaultTimeZone to an Area/Location name, or UTC — list every accepted one with bun -e \"console.log(Intl.supportedValuesOf('timeZone').join('\\n'))\" — where a legacy single-label name swaps mechanically (Japan → Asia/Tokyo, GB → Europe/London, Universal → UTC), while an abbreviation or numeric offset (CET, EST5EDT, +01:00) carries no DST rule and has no replacement, so name the city whose clock you mean (Europe/Paris, America/New_York)";
|
|
258
|
+
|
|
255
259
|
function validate(config: AppConfig): void {
|
|
256
260
|
const issues: string[] = [];
|
|
261
|
+
// Zero or one entry: the zone's own remedy, carried only when the zone is what failed.
|
|
262
|
+
const zoneFix: string[] = [];
|
|
257
263
|
|
|
258
264
|
if (!NAME_RE.test(config.name)) {
|
|
259
265
|
issues.push(`name "${config.name}" must match ${String(NAME_RE)}`);
|
|
@@ -265,8 +271,14 @@ function validate(config: AppConfig): void {
|
|
|
265
271
|
if (!config.locales.includes(config.defaultLocale)) {
|
|
266
272
|
issues.push(`defaultLocale "${config.defaultLocale}" is not in locales`);
|
|
267
273
|
}
|
|
268
|
-
|
|
269
|
-
|
|
274
|
+
// `@ultimat3/time`'s rule, restated because tier 0 cannot import tier 1 — see
|
|
275
|
+
// `time-zone-name.ts`. One validator means a zone `app.config.ts` accepts is a zone every
|
|
276
|
+
// `format` call, `task()` and `toZoned` below it can then do arithmetic in.
|
|
277
|
+
if (!isIanaZoneName(config.defaultTimeZone)) {
|
|
278
|
+
issues.push(
|
|
279
|
+
`defaultTimeZone "${config.defaultTimeZone}" is not an IANA Area/Location zone name`,
|
|
280
|
+
);
|
|
281
|
+
zoneFix.push(TIMEZONE_FIX);
|
|
270
282
|
}
|
|
271
283
|
if (!CURRENCY_RE.test(config.defaultCurrency)) {
|
|
272
284
|
issues.push(`defaultCurrency "${config.defaultCurrency}" is not a 3-letter ISO 4217 code`);
|
|
@@ -284,7 +296,9 @@ function validate(config: AppConfig): void {
|
|
|
284
296
|
if (issues.length > 0) {
|
|
285
297
|
throw new ConfigInvalidError({
|
|
286
298
|
cause: issues.join('; '),
|
|
287
|
-
|
|
299
|
+
// The generic instruction goes LAST so the fix line still ends in a command that can be
|
|
300
|
+
// pasted — a trailing `.` after `x verify` is a command nobody can run.
|
|
301
|
+
fix: [...zoneFix, BASE_FIX].join('. '),
|
|
288
302
|
meta: { issues },
|
|
289
303
|
});
|
|
290
304
|
}
|
package/src/context.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// service bag reach every layer through AsyncLocalStorage instead of being threaded as
|
|
3
3
|
// parameters — otherwise every signature in the framework grows a `ctx` argument twice.
|
|
4
4
|
|
|
5
|
-
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
6
5
|
import { type Actor, anonymousActor } from './actor';
|
|
6
|
+
import { asyncContext } from './async-context';
|
|
7
7
|
import { type Clock, systemClock } from './clock';
|
|
8
8
|
import { UltimateError } from './errors';
|
|
9
9
|
import { traceId as newTraceId, uuid } from './ids';
|
|
@@ -72,7 +72,12 @@ export interface CtxInit {
|
|
|
72
72
|
|
|
73
73
|
export type CtxPatch = Omit<CtxInit, 'requestId'>;
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
/**
|
|
76
|
+
* `async-context.ts` owns why this is a lazily-opened seam rather than a module-scope
|
|
77
|
+
* `new AsyncLocalStorage()`, and why a browser gets `undefined` from a read and an error from a
|
|
78
|
+
* write. It is the same seam `telemetry.ts` and `impersonate.ts` open, on purpose: one answer.
|
|
79
|
+
*/
|
|
80
|
+
const requestContext = asyncContext<Ctx>('the request context');
|
|
76
81
|
|
|
77
82
|
const neverAborted = new AbortController().signal;
|
|
78
83
|
|
|
@@ -133,16 +138,16 @@ export function createContext(init: CtxInit = {}): Ctx {
|
|
|
133
138
|
}
|
|
134
139
|
|
|
135
140
|
export function runWithContext<T>(ctx: Ctx, fn: () => T): T {
|
|
136
|
-
return
|
|
141
|
+
return requestContext.run(ctx, fn);
|
|
137
142
|
}
|
|
138
143
|
|
|
139
144
|
/** The context, or `undefined` outside a request. Prefer `useContext()` in app code. */
|
|
140
145
|
export function tryUseContext(): Ctx | undefined {
|
|
141
|
-
return
|
|
146
|
+
return requestContext.get();
|
|
142
147
|
}
|
|
143
148
|
|
|
144
149
|
export function useContext(): Ctx {
|
|
145
|
-
const ctx =
|
|
150
|
+
const ctx = tryUseContext();
|
|
146
151
|
if (ctx === undefined) {
|
|
147
152
|
throw new UltimateError({
|
|
148
153
|
code: 'X_NO_CONTEXT',
|
|
@@ -154,7 +159,7 @@ export function useContext(): Ctx {
|
|
|
154
159
|
}
|
|
155
160
|
|
|
156
161
|
export function hasContext(): boolean {
|
|
157
|
-
return
|
|
162
|
+
return tryUseContext() !== undefined;
|
|
158
163
|
}
|
|
159
164
|
|
|
160
165
|
/**
|
|
@@ -183,7 +188,7 @@ export function withChildContext<T>(patch: CtxPatch, fn: () => T): T {
|
|
|
183
188
|
signal: patch.signal ?? parent.signal,
|
|
184
189
|
services: { ...carried, ...(patch.services ?? {}) },
|
|
185
190
|
});
|
|
186
|
-
return
|
|
191
|
+
return requestContext.run(child, fn);
|
|
187
192
|
}
|
|
188
193
|
|
|
189
194
|
/** Resolve a late-bound service. Throws `X_SERVICE_MISSING` rather than returning undefined. */
|
|
@@ -222,7 +227,7 @@ export function throwIfAborted(ctx: Ctx = useContext()): void {
|
|
|
222
227
|
* never mistaken for the customer's own.
|
|
223
228
|
*/
|
|
224
229
|
setLoggerContextFields(() => {
|
|
225
|
-
const ctx =
|
|
230
|
+
const ctx = tryUseContext();
|
|
226
231
|
if (ctx === undefined) return undefined;
|
|
227
232
|
const { actor } = ctx;
|
|
228
233
|
return {
|
package/src/error-codes.ts
CHANGED
|
@@ -28,6 +28,7 @@ export function errorDocsUrl(code: string): string {
|
|
|
28
28
|
/** Codes owned by `@ultimat3/core`. Every other package calls `registerErrorCodes()`. */
|
|
29
29
|
const CORE_CODE_TITLES = {
|
|
30
30
|
X_ABORTED: 'operation aborted',
|
|
31
|
+
X_ASYNC_CONTEXT_UNAVAILABLE: 'async context unavailable',
|
|
31
32
|
X_CONFIG_INVALID: 'app.config.ts is invalid',
|
|
32
33
|
X_CURSOR_INVALID: 'pagination cursor is malformed, tampered with or from another query',
|
|
33
34
|
X_CURSOR_SECRET_DEV: 'cursors are signed with the shipped development key',
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Single responsibility: the GEOMETRY of a resize — output box, padded inner area, drawn size —
|
|
2
|
+
// and the source-over composite that places the drawn artwork on it. `Bun.Image` resamples but
|
|
3
|
+
// cannot letterbox, pad or crop, so this is the half of a transform it does not do; keeping the
|
|
4
|
+
// arithmetic here is also what lets a caller ask for the box before any pixel exists.
|
|
5
|
+
|
|
6
|
+
import { parseColor } from './color';
|
|
7
|
+
import { imageUnsupported } from './errors';
|
|
8
|
+
import { assertPixelBudget, createRaster, type ImageSize, type Raster } from './raster';
|
|
9
|
+
|
|
10
|
+
export type ImageFit = 'cover' | 'contain';
|
|
11
|
+
|
|
12
|
+
export interface ResizeSpec {
|
|
13
|
+
readonly width?: number | undefined;
|
|
14
|
+
readonly height?: number | undefined;
|
|
15
|
+
/** Default 'contain'. */
|
|
16
|
+
readonly fit?: ImageFit | undefined;
|
|
17
|
+
/** Fraction of the shorter OUTPUT edge left empty on every side. `0 <= padding < 0.5`. */
|
|
18
|
+
readonly padding?: number | undefined;
|
|
19
|
+
/** '#rgb' | '#rgba' | '#rrggbb' | '#rrggbbaa' | 'transparent'. Default transparent. */
|
|
20
|
+
readonly background?: string | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function assertDimension(value: number, field: string): void {
|
|
24
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
25
|
+
throw imageUnsupported(
|
|
26
|
+
`resize ${field} is ${value}, which is not a whole number of pixels above zero`,
|
|
27
|
+
`pass an integer ${field} of 1 or more, or omit it to derive it from the source`,
|
|
28
|
+
{ field, value },
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The output CANVAS size. A single-axis request clamps to the source: asking for `width: 2000`
|
|
35
|
+
* of a 400px original must not invent 1600 pixels of blur, it must hand back the 400.
|
|
36
|
+
*/
|
|
37
|
+
export function fitBox(source: ImageSize, spec: ResizeSpec): ImageSize {
|
|
38
|
+
const { width, height } = spec;
|
|
39
|
+
if (width !== undefined) assertDimension(width, 'width');
|
|
40
|
+
if (height !== undefined) assertDimension(height, 'height');
|
|
41
|
+
if (width !== undefined && height !== undefined) return { width, height };
|
|
42
|
+
if (width !== undefined) {
|
|
43
|
+
const w = Math.min(width, source.width);
|
|
44
|
+
return { width: w, height: Math.max(1, Math.round((w * source.height) / source.width)) };
|
|
45
|
+
}
|
|
46
|
+
if (height !== undefined) {
|
|
47
|
+
const h = Math.min(height, source.height);
|
|
48
|
+
return { width: Math.max(1, Math.round((h * source.width) / source.height)), height: h };
|
|
49
|
+
}
|
|
50
|
+
return { width: source.width, height: source.height };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The size the source is DRAWN at inside `box` — no letterbox, no crop maths. May upscale. */
|
|
54
|
+
export function scaledToFit(source: ImageSize, box: ImageSize, fit: ImageFit): ImageSize {
|
|
55
|
+
const x = box.width / source.width;
|
|
56
|
+
const y = box.height / source.height;
|
|
57
|
+
const scale = fit === 'cover' ? Math.max(x, y) : Math.min(x, y);
|
|
58
|
+
return {
|
|
59
|
+
width: Math.max(1, Math.round(source.width * scale)),
|
|
60
|
+
height: Math.max(1, Math.round(source.height * scale)),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The whole plan for one transform, decided before a pixel is touched. */
|
|
65
|
+
export interface Layout {
|
|
66
|
+
/** The output canvas. */
|
|
67
|
+
readonly box: ImageSize;
|
|
68
|
+
/** Pixels of padding on each edge. */
|
|
69
|
+
readonly pad: number;
|
|
70
|
+
/** The area inside the padding the artwork may occupy. */
|
|
71
|
+
readonly inner: ImageSize;
|
|
72
|
+
/** What the source is resampled to before it is placed. */
|
|
73
|
+
readonly drawn: ImageSize;
|
|
74
|
+
/** Parsed once, here, so an unspellable colour is refused before any pixel is produced. */
|
|
75
|
+
readonly background: readonly [number, number, number, number];
|
|
76
|
+
/** False when `drawn` IS the box and nothing shows through — the resampler's output is the answer. */
|
|
77
|
+
readonly needsCanvas: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function layOut(source: ImageSize, spec: ResizeSpec): Layout {
|
|
81
|
+
const box = fitBox(source, spec);
|
|
82
|
+
assertPixelBudget(box.width, box.height, 'resize');
|
|
83
|
+
const padding = spec.padding ?? 0;
|
|
84
|
+
if (!Number.isFinite(padding) || padding < 0 || padding >= 0.5) {
|
|
85
|
+
throw imageUnsupported(
|
|
86
|
+
`resize padding is ${padding}, outside the 0 <= padding < 0.5 range`,
|
|
87
|
+
'pass a fraction of the shorter output edge, e.g. 0.1 for a 10% border on every side',
|
|
88
|
+
{ padding },
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const pad = Math.round(Math.min(box.width, box.height) * padding);
|
|
92
|
+
const inner = { width: box.width - 2 * pad, height: box.height - 2 * pad };
|
|
93
|
+
if (inner.width < 1 || inner.height < 1) {
|
|
94
|
+
throw imageUnsupported(
|
|
95
|
+
`padding ${padding} leaves no room inside a ${box.width}x${box.height} output`,
|
|
96
|
+
'lower the padding or raise the requested width and height',
|
|
97
|
+
{ padding, pad, width: box.width, height: box.height },
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const drawn = scaledToFit(source, inner, spec.fit ?? 'contain');
|
|
101
|
+
// Parsed even when the fast path will not use it: 'chartreuse' must be refused whether or not
|
|
102
|
+
// the geometry happens to hide the colour, or the rejection depends on the source's dimensions.
|
|
103
|
+
const background = parseColor(spec.background ?? 'transparent');
|
|
104
|
+
// An opaque background still shows THROUGH a source with alpha, so it needs the canvas even at
|
|
105
|
+
// full bleed. A transparent one does not, and skipping it keeps a plain `srcset` variant out of
|
|
106
|
+
// the RGBA round trip entirely.
|
|
107
|
+
const needsCanvas =
|
|
108
|
+
drawn.width !== box.width || drawn.height !== box.height || background[3] !== 0;
|
|
109
|
+
return { box, pad, inner, drawn, background, needsCanvas };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function fill(canvas: Raster, color: readonly [number, number, number, number]): void {
|
|
113
|
+
const [r, g, b, a] = color;
|
|
114
|
+
// A zero-alpha background is canonicalised to all-zero, matching the composite's own
|
|
115
|
+
// `outA === 0 -> outC = 0`: '#ff000000' and 'transparent' must not produce different bytes.
|
|
116
|
+
if (a === 0) return;
|
|
117
|
+
const { pixels } = canvas;
|
|
118
|
+
for (let i = 0; i < pixels.length; i += 4) {
|
|
119
|
+
pixels[i] = r;
|
|
120
|
+
pixels[i + 1] = g;
|
|
121
|
+
pixels[i + 2] = b;
|
|
122
|
+
pixels[i + 3] = a;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Source-over. `outA === 0` means every contributor was transparent — the colour is nothing. */
|
|
127
|
+
function blend(dst: Uint8ClampedArray, d: number, s: Uint8ClampedArray, p: number): void {
|
|
128
|
+
const sa = s[p + 3] ?? 0;
|
|
129
|
+
if (sa === 0) return;
|
|
130
|
+
const da = dst[d + 3] ?? 0;
|
|
131
|
+
if (sa === 255 || da === 0) {
|
|
132
|
+
dst[d] = s[p] ?? 0;
|
|
133
|
+
dst[d + 1] = s[p + 1] ?? 0;
|
|
134
|
+
dst[d + 2] = s[p + 2] ?? 0;
|
|
135
|
+
dst[d + 3] = sa;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const sf = sa / 255;
|
|
139
|
+
const df = (da / 255) * (1 - sf);
|
|
140
|
+
const outA = sf + df;
|
|
141
|
+
dst[d] = ((s[p] ?? 0) * sf + (dst[d] ?? 0) * df) / outA;
|
|
142
|
+
dst[d + 1] = ((s[p + 1] ?? 0) * sf + (dst[d + 1] ?? 0) * df) / outA;
|
|
143
|
+
dst[d + 2] = ((s[p + 2] ?? 0) * sf + (dst[d + 2] ?? 0) * df) / outA;
|
|
144
|
+
dst[d + 3] = outA * 255;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The artwork, centred in the inner area and clipped to it — that clip is exactly the `cover`
|
|
149
|
+
* crop, so one blit serves both fits.
|
|
150
|
+
*/
|
|
151
|
+
export function composeOnto(art: Raster, layout: Layout): Raster {
|
|
152
|
+
const { box, pad, inner } = layout;
|
|
153
|
+
const canvas = createRaster(box.width, box.height, 'resize');
|
|
154
|
+
fill(canvas, layout.background);
|
|
155
|
+
const ox = pad + Math.round((inner.width - art.width) / 2);
|
|
156
|
+
const oy = pad + Math.round((inner.height - art.height) / 2);
|
|
157
|
+
const x1 = Math.min(pad + inner.width, ox + art.width);
|
|
158
|
+
const y1 = Math.min(pad + inner.height, oy + art.height);
|
|
159
|
+
for (let y = Math.max(pad, oy); y < y1; y += 1) {
|
|
160
|
+
for (let x = Math.max(pad, ox); x < x1; x += 1) {
|
|
161
|
+
blend(
|
|
162
|
+
canvas.pixels,
|
|
163
|
+
(y * canvas.width + x) * 4,
|
|
164
|
+
art.pixels,
|
|
165
|
+
((y - oy) * art.width + (x - ox)) * 4,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return canvas;
|
|
170
|
+
}
|
package/src/image/errors.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
// Single responsibility: the three failure modes of the image pipeline, as coded errors
|
|
2
|
-
//
|
|
3
|
-
// "unsupported" needs to know which format
|
|
1
|
+
// Single responsibility: the three failure modes of the image pipeline, as coded errors, and the
|
|
2
|
+
// one translation of `Bun.Image`'s `ERR_IMAGE_*` rejections into them. Every one names the format
|
|
3
|
+
// AND a runnable way forward, because an agent that hits "unsupported" needs to know which format
|
|
4
|
+
// to ask for instead, not that it lost.
|
|
4
5
|
|
|
6
|
+
import { renderThrowable, stringField } from '../error-render';
|
|
5
7
|
import { UltimateError } from '../errors';
|
|
6
8
|
|
|
7
9
|
export class ImageUnsupportedError extends UltimateError {
|
|
@@ -56,3 +58,33 @@ export const imageTooLarge = (
|
|
|
56
58
|
'downscale the source before it reaches the pipeline, or raise MAX_IMAGE_PIXELS deliberately',
|
|
57
59
|
meta,
|
|
58
60
|
);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `Bun.Image` rejects with a plain `Error` carrying a stable `error.code`. This is the ONE place
|
|
64
|
+
* that code is read: a caller branching on `ERR_IMAGE_*` would be a second vocabulary for the same
|
|
65
|
+
* three failures, and `X_IMAGE_*` is the one the rest of the framework, the wiki and `x errors
|
|
66
|
+
* explain` already know. Unknown codes land on decode-failed rather than on a bare `Error`.
|
|
67
|
+
*/
|
|
68
|
+
const UNSUPPORTED_FIX =
|
|
69
|
+
"request 'png', 'jpeg' or 'webp' — AVIF and HEIC need an OS codec the portable backend never " +
|
|
70
|
+
'uses, so route those through an ImageTransformDriver (a CDN or an external encoder)';
|
|
71
|
+
|
|
72
|
+
const UNKNOWN_FORMAT_FIX =
|
|
73
|
+
're-export the source as PNG, JPEG or WebP: `file <path>` reports what these bytes actually are';
|
|
74
|
+
|
|
75
|
+
export function imageFromBunError(value: unknown, doing: string): UltimateError {
|
|
76
|
+
// `renderThrowable`, never `${value}` — the rejection is Bun's value, not ours, and a cause that
|
|
77
|
+
// throws while formatting itself replaces the refusal with a TypeError nothing catches by code.
|
|
78
|
+
const cause = `${doing}: ${renderThrowable(value)}`;
|
|
79
|
+
const code = stringField(value, 'code');
|
|
80
|
+
if (code === 'ERR_IMAGE_FORMAT_UNSUPPORTED') {
|
|
81
|
+
return new ImageUnsupportedError(cause, UNSUPPORTED_FIX, { bunCode: code });
|
|
82
|
+
}
|
|
83
|
+
if (code === 'ERR_IMAGE_UNKNOWN_FORMAT') {
|
|
84
|
+
return new ImageUnsupportedError(cause, UNKNOWN_FORMAT_FIX, { bunCode: code });
|
|
85
|
+
}
|
|
86
|
+
if (code === 'ERR_IMAGE_TOO_MANY_PIXELS') {
|
|
87
|
+
return imageTooLarge(cause, { bunCode: code });
|
|
88
|
+
}
|
|
89
|
+
return imageDecodeFailed(cause, code === undefined ? {} : { bunCode: code });
|
|
90
|
+
}
|