@effected/images 0.0.0-stage → 0.1.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/ImageBackend.js +117 -0
- package/ImageBackendError.js +28 -0
- package/ImageCache.js +70 -0
- package/ImageCacheKey.js +56 -0
- package/ImageCacheKeyError.js +31 -0
- package/ImageFacts.js +78 -0
- package/ImageFormat.js +55 -0
- package/ImageGenerateError.js +34 -0
- package/ImageParseError.js +35 -0
- package/LICENSE +21 -0
- package/README.md +151 -2
- package/cache.d.ts +257 -0
- package/cache.js +8 -0
- package/index.d.ts +96 -0
- package/index.js +5 -0
- package/internal/avif.js +95 -0
- package/internal/bytes.js +14 -0
- package/internal/canonical.js +48 -0
- package/internal/detect.js +31 -0
- package/internal/digest.js +6 -0
- package/internal/gif.js +9 -0
- package/internal/jpeg.js +41 -0
- package/internal/png.js +15 -0
- package/internal/result.js +21 -0
- package/internal/webp.js +33 -0
- package/package.json +52 -5
- package/tsdoc-metadata.json +11 -0
package/README.md
CHANGED
|
@@ -1,3 +1,152 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @effected/images
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@effected/images)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
|
|
8
|
+
Image facts from bytes, and a cache for generated images. `ImageFacts` reads the format, MIME type and pixel dimensions of a PNG, JPEG, GIF, WebP or AVIF file straight from its header, with one typed failure for bytes that are not a readable image. `ImageCache` answers "have I already generated an image from these parameters?", so a build stops re-running an expensive renderer such as an Open Graph card generator on every run.
|
|
9
|
+
|
|
10
|
+
> **Pre-`1.0.0`.** This package is part of the `@effected/*` kit, built on stable
|
|
11
|
+
> Effect v4 (`effect` `^4.0.0`) and still in `0.x` development. Stable Effect
|
|
12
|
+
> makes a kit `1.0.0` possible, not automatic. To keep your `effect` and
|
|
13
|
+
> `@effect/*` versions on the line the kit is built and tested against, install
|
|
14
|
+
> [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
|
|
15
|
+
>
|
|
16
|
+
> **Stability: unstable.** This package's API surface is not yet considered
|
|
17
|
+
> complete and may change across `0.x` releases. Pin an exact version — even a
|
|
18
|
+
> package marked *stable* before `1.0.0` can introduce a breaking change by
|
|
19
|
+
> accident, and an exact pin turns that into a type-check error rather than a
|
|
20
|
+
> runtime surprise. Full policy: [release strategy](https://github.com/spencerbeggs/effected#release-strategy).
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install @effected/images effect
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pnpm add @effected/images effect
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Requires Node.js >=24.11.0. `effect` v4 is the only peer dependency, and the only dependency of any kind — the format readers are in-house.
|
|
33
|
+
|
|
34
|
+
All `@effected/*` packages are ESM-only: the exports maps publish only `import` conditions, so `require()` fails with Node's `ERR_PACKAGE_PATH_NOT_EXPORTED`. Import from an ES module.
|
|
35
|
+
|
|
36
|
+
## Image facts
|
|
37
|
+
|
|
38
|
+
The root entry is pure: no IO, no services. `ImageFacts.fromBytesResult` is the synchronous engine, usable from a script with no Effect runtime, and `ImageFacts.fromBytes` is the same read as an `Effect`.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { ImageFacts } from "@effected/images";
|
|
42
|
+
import { Effect, Result } from "effect";
|
|
43
|
+
|
|
44
|
+
declare const bytes: Uint8Array;
|
|
45
|
+
|
|
46
|
+
// Synchronous: a Result, never a throw.
|
|
47
|
+
const result = ImageFacts.fromBytesResult(bytes);
|
|
48
|
+
if (Result.isSuccess(result)) {
|
|
49
|
+
const { format, mimeType, width, height } = result.success; // "png", "image/png", 1200, 630
|
|
50
|
+
const file = `card.${result.success.extension}`; // "card.png"; a JPEG is written ".jpg"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Effect: fails with ImageParseError.
|
|
54
|
+
const facts = ImageFacts.fromBytes(bytes).pipe(
|
|
55
|
+
Effect.catchTag("ImageParseError", (e) => Effect.logWarning(`not an image: ${e.reason}`)),
|
|
56
|
+
);
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`mimeType` is one of the five media types (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/avif`), and `extension` is the conventional file extension: `jpg` for `jpeg`, the format name otherwise. `extension` is a getter derived from `format`, so it is not part of the encoded form, and a spread such as `{ ...facts }` drops it.
|
|
60
|
+
|
|
61
|
+
`ImageParseError.reason` is `unrecognized` (no known signature), `truncated` (a recognized file cut short) or `malformed` (a header that contradicts itself). Readers never throw on any input, bounds-check every length before reading it, and bound the JPEG and AVIF walks with a step budget.
|
|
62
|
+
|
|
63
|
+
## The generated-image cache
|
|
64
|
+
|
|
65
|
+
`@effected/images/cache` caches generated bytes under a key derived from the parameters that produced them. `ImageCacheKey.fromParams` encodes the parameters through your `Schema`, serializes them canonically and digests them with a `salt` — the identity of your generator, bumped when its template changes so every old entry misses.
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { ImageBackend, ImageCache, ImageCacheKey } from "@effected/images/cache";
|
|
69
|
+
import { NodeServices } from "@effect/platform-node";
|
|
70
|
+
import { Effect, Layer, Schema } from "effect";
|
|
71
|
+
|
|
72
|
+
const CardParams = Schema.Struct({ title: Schema.String, theme: Schema.String });
|
|
73
|
+
declare const renderCard: () => Effect.Effect<Uint8Array>; // your renderer
|
|
74
|
+
|
|
75
|
+
const CacheLive = ImageCache.layer.pipe(
|
|
76
|
+
Layer.provide(ImageBackend.layerDirectory({ directory: ".cache/og" })),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const program = Effect.gen(function* () {
|
|
80
|
+
const cache = yield* ImageCache;
|
|
81
|
+
const key = yield* ImageCacheKey.fromParams(
|
|
82
|
+
CardParams,
|
|
83
|
+
{ title: "Hello", theme: "dark" },
|
|
84
|
+
{ salt: "og-card-v3", namespace: "og" },
|
|
85
|
+
);
|
|
86
|
+
const { bytes, facts, hit } = yield* cache.getOrGenerate(
|
|
87
|
+
key,
|
|
88
|
+
renderCard, // () => Effect<Uint8Array, E, R> - runs only on a miss
|
|
89
|
+
{ accept: ["png"] },
|
|
90
|
+
);
|
|
91
|
+
return { bytes, width: facts.width, hit };
|
|
92
|
+
}).pipe(Effect.provide(CacheLive), Effect.provide(NodeServices.layer));
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`getOrGenerate` returns the bytes, their `ImageFacts` and whether it was a `hit`. With `accept`, the result's `facts.format` is typed as the accepted formats, so `["png", "jpeg", "webp"]` gives `"png" | "jpeg" | "webp"`; without it, every format. A stored entry that no longer parses, or whose format is outside `accept`, is treated as a miss and overwritten. A generator result that is empty, unparseable or outside `accept` fails with `ImageGenerateError` and is never stored. Backend failures surface as `ImageBackendError`; the generator's own error passes through untouched.
|
|
96
|
+
|
|
97
|
+
### A Promise-returning renderer
|
|
98
|
+
|
|
99
|
+
When the renderer returns a Promise, wrap it with both `try` and `catch` so its own error reaches the caller:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { Effect } from "effect";
|
|
103
|
+
|
|
104
|
+
declare const render: (info: { readonly title: string }) => Promise<Uint8Array>;
|
|
105
|
+
declare const info: { readonly title: string };
|
|
106
|
+
|
|
107
|
+
const generate = () => Effect.tryPromise({ try: () => render(info), catch: (cause) => cause });
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The one-argument `Effect.tryPromise(() => render(info))` wraps a rejection in `UnknownError`, so the generator's own error is lost.
|
|
111
|
+
|
|
112
|
+
### Switching the cache off
|
|
113
|
+
|
|
114
|
+
`ImageBackend.layerNone` stores nothing: `get` always misses and `set` discards. Provide it when caching is disabled and keep the same `getOrGenerate` call; every call runs the generator and returns `hit: false`.
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { ImageBackend } from "@effected/images/cache";
|
|
118
|
+
|
|
119
|
+
declare const cachingEnabled: boolean;
|
|
120
|
+
const backend = cachingEnabled ? ImageBackend.layerDirectory({ directory: ".cache/og" }) : ImageBackend.layerNone;
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The key digest uses core `Crypto`, and the directory backend uses `FileSystem` and `Path`. On Node, `NodeServices.layer` from `@effect/platform-node` provides all three.
|
|
124
|
+
|
|
125
|
+
### Bring your own store
|
|
126
|
+
|
|
127
|
+
`ImageBackend` is a port, not a dependency. `ImageBackend.layerFrom(key)` adapts any service whose `get` and `set` match structurally — for example the `Cache` from `@effected/store`, with no edge between the two packages:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import { Cache } from "@effected/store";
|
|
131
|
+
import { ImageBackend } from "@effected/images/cache";
|
|
132
|
+
|
|
133
|
+
const StoreBacked = ImageBackend.layerFrom(Cache); // provide Cache underneath
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The directory backend stores `<directory>/<key>.<ext>` through a temp file and an atomic rename. It accepts and ignores `tags`.
|
|
137
|
+
|
|
138
|
+
## Features
|
|
139
|
+
|
|
140
|
+
- `ImageFacts` — format, MIME type, file extension and pixel dimensions as stored in the file; sync `fromBytesResult` and Effect `fromBytes`.
|
|
141
|
+
- `ImageExtension` / `ImageMimeType` — the extension and media-type unions.
|
|
142
|
+
- `ImageFormat` — the literal union `png | jpeg | gif | webp | avif`.
|
|
143
|
+
- `ImageParseError` — the one parse failure, with a `reason` and, once a signature matched, the `format`.
|
|
144
|
+
- `ImageCacheKey` / `ImageCacheKeyError` — content-addressed keys from schema-encoded parameters; failure reasons `encode`, `non-json` and `digest`.
|
|
145
|
+
- `ImageBackend` / `ImageBackendError` — the storage port, a directory backend, a no-store backend and a structural adapter.
|
|
146
|
+
- `ImageCache` / `ImageGenerateError` — `getOrGenerate` with an optional `accept` format list that narrows the result's format.
|
|
147
|
+
|
|
148
|
+
SVG, BMP, ICO, TIFF, HEIC, JPEG XL, EXIF orientation, image transformation and cache eviction are out of scope.
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
[MIT](LICENSE)
|
package/cache.d.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { Context, Crypto, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
|
|
2
|
+
import * as Images from "@effected/images";
|
|
3
|
+
//#region src/ImageBackendError.d.ts
|
|
4
|
+
declare const ImageBackendError_base: Schema.Class<ImageBackendError, Schema.TaggedStruct<"ImageBackendError", {
|
|
5
|
+
/** The backend operation that failed. */
|
|
6
|
+
readonly operation: Schema.Literals<readonly ["get", "set"]>;
|
|
7
|
+
/** The key involved. */
|
|
8
|
+
readonly key: Schema.String;
|
|
9
|
+
/** The underlying failure, preserved structurally. */
|
|
10
|
+
readonly cause: Schema.Defect;
|
|
11
|
+
}>, import("effect/Cause").YieldableError>;
|
|
12
|
+
/**
|
|
13
|
+
* An image backend read or write failed.
|
|
14
|
+
*
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
export declare class ImageBackendError extends ImageBackendError_base {
|
|
18
|
+
get message(): string;
|
|
19
|
+
}
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/ImageBackend.d.ts
|
|
22
|
+
/**
|
|
23
|
+
* Bytes and media type a backend holds for one key.
|
|
24
|
+
*
|
|
25
|
+
* @public
|
|
26
|
+
*/
|
|
27
|
+
interface StoredImage {
|
|
28
|
+
readonly value: Uint8Array;
|
|
29
|
+
readonly contentType: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* What {@link ImageBackendShape.set} writes.
|
|
33
|
+
*
|
|
34
|
+
* @public
|
|
35
|
+
*/
|
|
36
|
+
interface ImageBackendSetParams {
|
|
37
|
+
readonly key: string;
|
|
38
|
+
readonly value: Uint8Array;
|
|
39
|
+
readonly contentType?: string;
|
|
40
|
+
readonly tags?: ReadonlyArray<string>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The port an image cache stores through.
|
|
44
|
+
*
|
|
45
|
+
* @remarks
|
|
46
|
+
* Failures are {@link ImageBackendError}. `@effected/store`'s `Cache` is adapted through
|
|
47
|
+
* `ImageBackend.layerFrom`, which {@link ImageBackendSource} describes structurally.
|
|
48
|
+
*
|
|
49
|
+
* @public
|
|
50
|
+
*/
|
|
51
|
+
interface ImageBackendShape {
|
|
52
|
+
readonly get: (key: string) => Effect.Effect<Option.Option<StoredImage>, ImageBackendError>;
|
|
53
|
+
readonly set: (params: ImageBackendSetParams) => Effect.Effect<void, ImageBackendError>;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Any service whose shape can stand behind the image cache.
|
|
57
|
+
*
|
|
58
|
+
* @remarks
|
|
59
|
+
* `@effected/store`'s `CacheShape` satisfies it: its `get` yields a wider `CacheEntry`, and its `set` accepts these
|
|
60
|
+
* params plus a `ttl`. Failures may be of any type; `ImageBackend.layerFrom` wraps them.
|
|
61
|
+
*
|
|
62
|
+
* @public
|
|
63
|
+
*/
|
|
64
|
+
interface ImageBackendSource {
|
|
65
|
+
readonly get: (key: string) => Effect.Effect<Option.Option<StoredImage>, unknown>;
|
|
66
|
+
readonly set: (params: ImageBackendSetParams) => Effect.Effect<void, unknown>;
|
|
67
|
+
}
|
|
68
|
+
declare const ImageBackend_base: Context.ServiceClass<ImageBackend, "@effected/images/ImageBackend", ImageBackendShape>;
|
|
69
|
+
/**
|
|
70
|
+
* The storage port behind `ImageCache`.
|
|
71
|
+
*
|
|
72
|
+
* @public
|
|
73
|
+
*/
|
|
74
|
+
export declare class ImageBackend extends ImageBackend_base {
|
|
75
|
+
/**
|
|
76
|
+
* The backend that stores nothing: `get` always misses and `set` discards the bytes.
|
|
77
|
+
*
|
|
78
|
+
* @remarks
|
|
79
|
+
* "The cache is off" as a layer, so a program keeps one `ImageCache.getOrGenerate` code path whether caching is
|
|
80
|
+
* enabled or not: provide this when it is off and `layerDirectory` (or `layerFrom`) when it is on. Every call runs
|
|
81
|
+
* the generator and returns `hit: false`; the generated bytes are still read and checked against `accept`. It needs
|
|
82
|
+
* nothing in `R`, never fails, and is a plain value, so it builds once however often it is provided.
|
|
83
|
+
*/
|
|
84
|
+
static readonly layerNone: Layer.Layer<ImageBackend>;
|
|
85
|
+
/**
|
|
86
|
+
* A backend over one directory: `<directory>/<key>.<ext>`.
|
|
87
|
+
*
|
|
88
|
+
* @remarks
|
|
89
|
+
* Writes go to a temp file in the same directory and are renamed into place, so a failed or interrupted write
|
|
90
|
+
* never leaves a file that reads as a hit. Tags are accepted and ignored — a directory has no tag index. Keys must
|
|
91
|
+
* be 64-char lowercase hex digests (as `ImageCacheKey` produces); anything else is a defect, so a key can never
|
|
92
|
+
* name a path outside the directory. No expiry or eviction.
|
|
93
|
+
*
|
|
94
|
+
* Each call mints a fresh layer; bind the result to a `const` or the backend builds twice.
|
|
95
|
+
*/
|
|
96
|
+
static layerDirectory(options: {
|
|
97
|
+
readonly directory: string;
|
|
98
|
+
}): Layer.Layer<ImageBackend, never, FileSystem.FileSystem | Path.Path>;
|
|
99
|
+
/**
|
|
100
|
+
* Adapt any structurally matching service, `@effected/store`'s `Cache` being the intended one, as the backend.
|
|
101
|
+
*
|
|
102
|
+
* @remarks
|
|
103
|
+
* `ImageBackend.layerFrom(Cache)` gives the image cache store's TTL, eviction and tag invalidation. Every source
|
|
104
|
+
* failure is wrapped as {@link ImageBackendError} with the original as `cause`.
|
|
105
|
+
*
|
|
106
|
+
* Each call mints a fresh layer; bind the result to a `const` or the backend builds twice.
|
|
107
|
+
*/
|
|
108
|
+
static layerFrom<I>(key: Context.Key<I, ImageBackendSource>): Layer.Layer<ImageBackend, never, I>;
|
|
109
|
+
}
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/ImageCacheKeyError.d.ts
|
|
112
|
+
declare const ImageCacheKeyError_base: Schema.Class<ImageCacheKeyError, Schema.TaggedStruct<"ImageCacheKeyError", {
|
|
113
|
+
/** Which step failed. */
|
|
114
|
+
readonly reason: Schema.Literals<readonly ["encode", "non-json", "digest"]>;
|
|
115
|
+
/** What exactly was wrong. */
|
|
116
|
+
readonly detail: Schema.String;
|
|
117
|
+
/** The schema error (`encode`) or platform error (`digest`). */
|
|
118
|
+
readonly cause: Schema.optionalKey<Schema.Defect>;
|
|
119
|
+
}>, import("effect/Cause").YieldableError>;
|
|
120
|
+
/**
|
|
121
|
+
* Cache-key parameters that could not be turned into a key.
|
|
122
|
+
*
|
|
123
|
+
* @remarks
|
|
124
|
+
* `encode`: the parameter schema rejected the params. `non-json`: the encoded form holds a value canonical JSON
|
|
125
|
+
* cannot carry (a `Date`, a `bigint`, a non-finite number, a class instance). `digest`: the `Crypto` service failed.
|
|
126
|
+
*
|
|
127
|
+
* @public
|
|
128
|
+
*/
|
|
129
|
+
export declare class ImageCacheKeyError extends ImageCacheKeyError_base {
|
|
130
|
+
get message(): string;
|
|
131
|
+
}
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/ImageCacheKey.d.ts
|
|
134
|
+
/**
|
|
135
|
+
* Options for {@link ImageCacheKey.fromParams}.
|
|
136
|
+
*
|
|
137
|
+
* @public
|
|
138
|
+
*/
|
|
139
|
+
interface ImageCacheKeyOptions {
|
|
140
|
+
/** The generator's identity: change it when the template changes, and every old key misses. */
|
|
141
|
+
readonly salt: string;
|
|
142
|
+
/** A grouping tag the backend records (store can invalidate by it); not part of the digest. */
|
|
143
|
+
readonly namespace: string;
|
|
144
|
+
}
|
|
145
|
+
declare const ImageCacheKey_base: Schema.Class<ImageCacheKey, Schema.Struct<{
|
|
146
|
+
/** Lowercase hex SHA-256 of `salt`, a NUL, and the canonical JSON of the encoded params. */
|
|
147
|
+
readonly digest: Schema.String;
|
|
148
|
+
/** The salt the digest was derived with. */
|
|
149
|
+
readonly salt: Schema.String;
|
|
150
|
+
/** The grouping tag. */
|
|
151
|
+
readonly namespace: Schema.String;
|
|
152
|
+
}>, {}>;
|
|
153
|
+
/**
|
|
154
|
+
* The identity of one generated image: a SHA-256 digest of the salt and the schema-encoded parameters.
|
|
155
|
+
*
|
|
156
|
+
* @public
|
|
157
|
+
*/
|
|
158
|
+
export declare class ImageCacheKey extends ImageCacheKey_base {
|
|
159
|
+
/**
|
|
160
|
+
* Derive a key.
|
|
161
|
+
*
|
|
162
|
+
* @remarks
|
|
163
|
+
* `params` are encoded through `schema`, so a transformation participates and the key reflects the wire form. The
|
|
164
|
+
* digest is core `Crypto`'s SHA-256, so `Crypto` is required — Node apps get it from `NodeServices.layer` or
|
|
165
|
+
* `NodeCrypto.layer`.
|
|
166
|
+
*/
|
|
167
|
+
static fromParams<S extends Schema.ConstraintEncoder<unknown>>(schema: S, params: S["Type"], options: ImageCacheKeyOptions): Effect.Effect<ImageCacheKey, ImageCacheKeyError, Crypto.Crypto>;
|
|
168
|
+
}
|
|
169
|
+
//#endregion
|
|
170
|
+
//#region src/ImageGenerateError.d.ts
|
|
171
|
+
declare const ImageGenerateError_base: Schema.Class<ImageGenerateError, Schema.TaggedStruct<"ImageGenerateError", {
|
|
172
|
+
/** Why the output was refused. */
|
|
173
|
+
readonly reason: Schema.Literals<readonly ["empty", "unparseable", "rejected-format"]>;
|
|
174
|
+
/** The output's format, when it was readable. */
|
|
175
|
+
readonly format: Schema.optionalKey<Schema.Literals<readonly ["png", "jpeg", "gif", "webp", "avif"]>>;
|
|
176
|
+
/** The formats the caller accepts, for `rejected-format`. */
|
|
177
|
+
readonly accept: Schema.optionalKey<Schema.$Array<Schema.Literals<readonly ["png", "jpeg", "gif", "webp", "avif"]>>>;
|
|
178
|
+
/** The parse failure, for `unparseable`. */
|
|
179
|
+
readonly cause: Schema.optionalKey<Schema.Defect>;
|
|
180
|
+
}>, import("effect/Cause").YieldableError>;
|
|
181
|
+
/**
|
|
182
|
+
* A generator produced bytes the cache will not store.
|
|
183
|
+
*
|
|
184
|
+
* @remarks
|
|
185
|
+
* `empty`: zero bytes. `unparseable`: not a readable image (`cause` is the `ImageParseError`).
|
|
186
|
+
* `rejected-format`: a readable image whose format is outside the caller's `accept` list.
|
|
187
|
+
*
|
|
188
|
+
* @public
|
|
189
|
+
*/
|
|
190
|
+
export declare class ImageGenerateError extends ImageGenerateError_base {
|
|
191
|
+
get message(): string;
|
|
192
|
+
}
|
|
193
|
+
//#endregion
|
|
194
|
+
//#region src/ImageCache.d.ts
|
|
195
|
+
/**
|
|
196
|
+
* Options for {@link ImageCacheShape.getOrGenerate}.
|
|
197
|
+
*
|
|
198
|
+
* @public
|
|
199
|
+
*/
|
|
200
|
+
interface GetOrGenerateOptions<F extends Images.ImageFormat = Images.ImageFormat> {
|
|
201
|
+
/**
|
|
202
|
+
* Formats the caller will use. A stored image outside it is a miss; a generated one outside it fails. The result's
|
|
203
|
+
* `facts.format` is narrowed to these formats.
|
|
204
|
+
*/
|
|
205
|
+
readonly accept?: ReadonlyArray<F>;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* What {@link ImageCacheShape.getOrGenerate} returns.
|
|
209
|
+
*
|
|
210
|
+
* @public
|
|
211
|
+
*/
|
|
212
|
+
interface ImageCacheResult<F extends Images.ImageFormat = Images.ImageFormat> {
|
|
213
|
+
/** The image bytes. */
|
|
214
|
+
readonly bytes: Uint8Array;
|
|
215
|
+
/** The facts read from those bytes, with `format` narrowed to the formats the call accepted. */
|
|
216
|
+
readonly facts: Images.ImageFacts & {
|
|
217
|
+
readonly format: F;
|
|
218
|
+
};
|
|
219
|
+
/** True when the bytes came from the backend rather than the generator. */
|
|
220
|
+
readonly hit: boolean;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The image cache service.
|
|
224
|
+
*
|
|
225
|
+
* @public
|
|
226
|
+
*/
|
|
227
|
+
interface ImageCacheShape {
|
|
228
|
+
/**
|
|
229
|
+
* Return the image stored under `key`, or run `generate`, check its output, store it and return it.
|
|
230
|
+
*
|
|
231
|
+
* @remarks
|
|
232
|
+
* Stored bytes are re-read with `ImageFacts`: unreadable bytes, or a format outside `accept`, are a miss and are
|
|
233
|
+
* overwritten. Generated bytes that are empty, unreadable or outside `accept` fail with `ImageGenerateError` and are
|
|
234
|
+
* never stored. Backend failures are surfaced, never swallowed. With `accept`, the result's `facts.format` is typed
|
|
235
|
+
* as the accepted formats; without it, every `ImageFormat`. The two cases are separate overloads so a format type
|
|
236
|
+
* argument can only be given alongside the `accept` list that enforces it at run time.
|
|
237
|
+
*/
|
|
238
|
+
readonly getOrGenerate: {
|
|
239
|
+
<E, R, const F extends Images.ImageFormat>(key: ImageCacheKey, generate: () => Effect.Effect<Uint8Array, E, R>, options: GetOrGenerateOptions<F> & {
|
|
240
|
+
readonly accept: ReadonlyArray<F>;
|
|
241
|
+
}): Effect.Effect<ImageCacheResult<F>, ImageGenerateError | ImageBackendError | E, R>;
|
|
242
|
+
<E, R>(key: ImageCacheKey, generate: () => Effect.Effect<Uint8Array, E, R>, options?: GetOrGenerateOptions): Effect.Effect<ImageCacheResult, ImageGenerateError | ImageBackendError | E, R>;
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
declare const ImageCache_base: Context.ServiceClass<ImageCache, "@effected/images/ImageCache", ImageCacheShape>;
|
|
246
|
+
/**
|
|
247
|
+
* A cache for generated images, keyed by `ImageCacheKey`, stored through {@link ImageBackend}.
|
|
248
|
+
*
|
|
249
|
+
* @public
|
|
250
|
+
*/
|
|
251
|
+
export declare class ImageCache extends ImageCache_base {
|
|
252
|
+
/** The cache over whichever {@link ImageBackend} the app provides. */
|
|
253
|
+
static readonly layer: Layer.Layer<ImageCache, never, ImageBackend>;
|
|
254
|
+
}
|
|
255
|
+
//#endregion
|
|
256
|
+
export type { GetOrGenerateOptions, ImageBackendSetParams, ImageBackendShape, ImageBackendSource, ImageCacheKeyOptions, ImageCacheResult, ImageCacheShape, StoredImage };
|
|
257
|
+
//# sourceMappingURL=cache.d.ts.map
|
package/cache.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ImageBackendError } from "./ImageBackendError.js";
|
|
2
|
+
import { ImageBackend } from "./ImageBackend.js";
|
|
3
|
+
import { ImageGenerateError } from "./ImageGenerateError.js";
|
|
4
|
+
import { ImageCache } from "./ImageCache.js";
|
|
5
|
+
import { ImageCacheKeyError } from "./ImageCacheKeyError.js";
|
|
6
|
+
import { ImageCacheKey } from "./ImageCacheKey.js";
|
|
7
|
+
|
|
8
|
+
export { ImageBackend, ImageBackendError, ImageCache, ImageCacheKey, ImageCacheKeyError, ImageGenerateError };
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Effect, Result, Schema } from "effect";
|
|
2
|
+
//#region src/ImageParseError.d.ts
|
|
3
|
+
declare const ImageParseError_base: Schema.Class<ImageParseError, Schema.TaggedStruct<"ImageParseError", {
|
|
4
|
+
/** Why the header could not be read. */
|
|
5
|
+
readonly reason: Schema.Literals<readonly ["unrecognized", "truncated", "malformed"]>;
|
|
6
|
+
/** The format whose signature matched, when one did. */
|
|
7
|
+
readonly format: Schema.optionalKey<Schema.Literals<readonly ["png", "jpeg", "gif", "webp", "avif"]>>;
|
|
8
|
+
/** What exactly was wrong. */
|
|
9
|
+
readonly detail: Schema.optionalKey<Schema.String>;
|
|
10
|
+
}>, import("effect/Cause").YieldableError>;
|
|
11
|
+
/**
|
|
12
|
+
* Bytes that could not be read as an image header.
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* `unrecognized`: no known signature. `truncated`: a signature matched but the
|
|
16
|
+
* bytes end before the dimension fields. `malformed`: a header field is
|
|
17
|
+
* impossible (a zero or out-of-range dimension, an inconsistent length, or a
|
|
18
|
+
* walk that exhausted its step budget).
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
export declare class ImageParseError extends ImageParseError_base {
|
|
23
|
+
get message(): string;
|
|
24
|
+
}
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/ImageFacts.d.ts
|
|
27
|
+
/**
|
|
28
|
+
* One of the five media types `ImageFacts.mimeType` holds, one per `ImageFormat`.
|
|
29
|
+
*
|
|
30
|
+
* @public
|
|
31
|
+
*/
|
|
32
|
+
type ImageMimeType = ImageFacts["mimeType"];
|
|
33
|
+
/**
|
|
34
|
+
* The conventional file extension for an image format, without the dot: `jpeg` is written `jpg`, the others keep
|
|
35
|
+
* their format name. The type of `ImageFacts#extension`.
|
|
36
|
+
*
|
|
37
|
+
* @public
|
|
38
|
+
*/
|
|
39
|
+
type ImageExtension = ImageFacts["extension"];
|
|
40
|
+
declare const ImageFacts_base: Schema.Class<ImageFacts, Schema.Struct<{
|
|
41
|
+
/** The detected format. */
|
|
42
|
+
readonly format: Schema.Literals<readonly ["png", "jpeg", "gif", "webp", "avif"]>;
|
|
43
|
+
/** The IANA media type for `format`: one of `image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/avif`. */
|
|
44
|
+
readonly mimeType: Schema.Literals<readonly ["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"]>;
|
|
45
|
+
/** Stored pixel width. */
|
|
46
|
+
readonly width: Schema.Int;
|
|
47
|
+
/** Stored pixel height. */
|
|
48
|
+
readonly height: Schema.Int;
|
|
49
|
+
}>, {}>;
|
|
50
|
+
/**
|
|
51
|
+
* What an image's header says about it: format, MIME type and stored pixel dimensions.
|
|
52
|
+
*
|
|
53
|
+
* @remarks
|
|
54
|
+
* Dimensions are as stored in the file; EXIF orientation is not applied.
|
|
55
|
+
*
|
|
56
|
+
* @public
|
|
57
|
+
*/
|
|
58
|
+
export declare class ImageFacts extends ImageFacts_base {
|
|
59
|
+
/**
|
|
60
|
+
* The conventional file extension for `format`, without the dot: `jpg` for `jpeg`, the format name otherwise.
|
|
61
|
+
*
|
|
62
|
+
* @remarks
|
|
63
|
+
* A getter, not a field: it is derived from `format` on read, so it is absent from the encoded form and a decoded
|
|
64
|
+
* value has it again. Name a written file with it instead of keeping a private format-to-extension table.
|
|
65
|
+
*/
|
|
66
|
+
get extension(): "png" | "jpg" | "gif" | "webp" | "avif";
|
|
67
|
+
/**
|
|
68
|
+
* Read facts from an image's bytes — the synchronous primitive.
|
|
69
|
+
*
|
|
70
|
+
* @remarks
|
|
71
|
+
* Reads only the header. Never throws: unknown bytes are `unrecognized`, a
|
|
72
|
+
* header that ends early is `truncated`, an impossible header is `malformed`.
|
|
73
|
+
*/
|
|
74
|
+
static fromBytesResult(bytes: Uint8Array): Result.Result<ImageFacts, ImageParseError>;
|
|
75
|
+
/**
|
|
76
|
+
* Read facts from an image's bytes — {@link ImageFacts.fromBytesResult} with a span.
|
|
77
|
+
*/
|
|
78
|
+
static readonly fromBytes: (bytes: Uint8Array) => Effect.Effect<ImageFacts, ImageParseError>;
|
|
79
|
+
}
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region src/ImageFormat.d.ts
|
|
82
|
+
/**
|
|
83
|
+
* The image formats `@effected/images` reads.
|
|
84
|
+
*
|
|
85
|
+
* @public
|
|
86
|
+
*/
|
|
87
|
+
export declare const ImageFormat: Schema.Literals<readonly ["png", "jpeg", "gif", "webp", "avif"]>;
|
|
88
|
+
/**
|
|
89
|
+
* One of the image formats `@effected/images` reads.
|
|
90
|
+
*
|
|
91
|
+
* @public
|
|
92
|
+
*/
|
|
93
|
+
export type ImageFormat = typeof ImageFormat.Type;
|
|
94
|
+
//#endregion
|
|
95
|
+
export type { ImageExtension, ImageMimeType };
|
|
96
|
+
//# sourceMappingURL=index.d.ts.map
|
package/index.js
ADDED
package/internal/avif.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { ascii, u32be } from "./bytes.js";
|
|
2
|
+
import { dimensions, malformed, truncated } from "./result.js";
|
|
3
|
+
import { Result } from "effect";
|
|
4
|
+
|
|
5
|
+
//#region src/internal/avif.ts
|
|
6
|
+
/** Boxes the walk may visit before it gives up. Hostile input cannot loop past it. */
|
|
7
|
+
const AVIF_BOX_BUDGET = 1024;
|
|
8
|
+
/** Brands scanned in the ftyp box, at most. */
|
|
9
|
+
const MAX_BRANDS = 64;
|
|
10
|
+
/** True when an ftyp box at offset 0 names `avif` or `avis` as its major or a compatible brand. */
|
|
11
|
+
const isAvifBrand = (b) => {
|
|
12
|
+
if (b.length < 12 || ascii(b, 4, 4) !== "ftyp") return false;
|
|
13
|
+
const brand = (o) => ascii(b, o, 4);
|
|
14
|
+
if (brand(8) === "avif" || brand(8) === "avis") return true;
|
|
15
|
+
const end = Math.min(u32be(b, 0), b.length);
|
|
16
|
+
for (let o = 16, n = 0; o + 4 <= end && n < MAX_BRANDS; o += 4, n++) if (brand(o) === "avif" || brand(o) === "avis") return true;
|
|
17
|
+
return false;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Only a top-level box can be cut short by the end of the bytes: below it the parent has already been
|
|
21
|
+
* verified to end inside the buffer, so more bytes could never complete a child that overruns it.
|
|
22
|
+
*/
|
|
23
|
+
const readBox = (b, offset, parentEnd, topLevel) => {
|
|
24
|
+
const short = topLevel ? truncated : malformed;
|
|
25
|
+
if (offset + 8 > b.length) return short("ended inside a box header");
|
|
26
|
+
let size = u32be(b, offset);
|
|
27
|
+
const type = ascii(b, offset + 4, 4);
|
|
28
|
+
let header = 8;
|
|
29
|
+
if (size === 1) {
|
|
30
|
+
if (offset + 16 > b.length) return short("ended inside a largesize header");
|
|
31
|
+
if (u32be(b, offset + 8) !== 0) return malformed(`box ${JSON.stringify(type)} largesize exceeds 32 bits`);
|
|
32
|
+
size = u32be(b, offset + 12);
|
|
33
|
+
header = 16;
|
|
34
|
+
} else if (size === 0) size = parentEnd - offset;
|
|
35
|
+
if (size < header) return malformed(`box ${JSON.stringify(type)} size ${size} is below its header`);
|
|
36
|
+
const end = offset + size;
|
|
37
|
+
if (topLevel && end > b.length) return truncated(`box ${JSON.stringify(type)} runs past the end of the bytes`);
|
|
38
|
+
if (end > parentEnd) return malformed(`box ${JSON.stringify(type)} overruns its parent`);
|
|
39
|
+
return Result.succeed({
|
|
40
|
+
type,
|
|
41
|
+
start: offset + header,
|
|
42
|
+
end
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
/** Every child box of [start, end), stopping early on failure. */
|
|
46
|
+
const children = (b, start, end, budget) => {
|
|
47
|
+
const out = [];
|
|
48
|
+
for (let offset = start; offset < end;) {
|
|
49
|
+
if (--budget.remaining < 0) return malformed("box walk exceeded its budget");
|
|
50
|
+
const box = readBox(b, offset, end, false);
|
|
51
|
+
if (Result.isFailure(box)) return Result.fail(box.failure);
|
|
52
|
+
out.push(box.success);
|
|
53
|
+
offset = box.success.end;
|
|
54
|
+
}
|
|
55
|
+
return Result.succeed(out);
|
|
56
|
+
};
|
|
57
|
+
/** Find the first child of type `type`; at top level its absence means the bytes ended early. */
|
|
58
|
+
const child = (b, start, end, type, budget, topLevel) => {
|
|
59
|
+
for (let offset = start; offset < end;) {
|
|
60
|
+
if (--budget.remaining < 0) return malformed("box walk exceeded its budget");
|
|
61
|
+
const box = readBox(b, offset, end, topLevel);
|
|
62
|
+
if (Result.isFailure(box)) return Result.fail(box.failure);
|
|
63
|
+
if (box.success.type === type) return box;
|
|
64
|
+
offset = box.success.end;
|
|
65
|
+
}
|
|
66
|
+
return topLevel ? truncated(`ended before the ${type} box`) : malformed(`no ${type} box`);
|
|
67
|
+
};
|
|
68
|
+
const readAvif = (b) => {
|
|
69
|
+
const budget = { remaining: AVIF_BOX_BUDGET };
|
|
70
|
+
const meta = child(b, 0, b.length, "meta", budget, true);
|
|
71
|
+
if (Result.isFailure(meta)) return Result.fail(meta.failure);
|
|
72
|
+
const metaChildren = meta.success.start + 4;
|
|
73
|
+
if (metaChildren > meta.success.end) return malformed("meta box is too short for its version and flags");
|
|
74
|
+
const iprp = child(b, metaChildren, meta.success.end, "iprp", budget, false);
|
|
75
|
+
if (Result.isFailure(iprp)) return Result.fail(iprp.failure);
|
|
76
|
+
const ipco = child(b, iprp.success.start, iprp.success.end, "ipco", budget, false);
|
|
77
|
+
if (Result.isFailure(ipco)) return Result.fail(ipco.failure);
|
|
78
|
+
const properties = children(b, ipco.success.start, ipco.success.end, budget);
|
|
79
|
+
if (Result.isFailure(properties)) return Result.fail(properties.failure);
|
|
80
|
+
let best;
|
|
81
|
+
for (const property of properties.success) {
|
|
82
|
+
if (property.type !== "ispe") continue;
|
|
83
|
+
if (property.end - property.start < 12) return malformed("ispe box is too short");
|
|
84
|
+
const width = u32be(b, property.start + 4);
|
|
85
|
+
const height = u32be(b, property.start + 8);
|
|
86
|
+
if (best === void 0 || width * height > best.width * best.height) best = {
|
|
87
|
+
width,
|
|
88
|
+
height
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return best === void 0 ? malformed("no ispe property") : dimensions(best.width, best.height);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
//#endregion
|
|
95
|
+
export { AVIF_BOX_BUDGET, isAvifBrand, readAvif };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/internal/bytes.ts
|
|
2
|
+
const u16be = (b, o) => b[o] << 8 | b[o + 1];
|
|
3
|
+
const u16le = (b, o) => b[o] | b[o + 1] << 8;
|
|
4
|
+
const u24le = (b, o) => b[o] | b[o + 1] << 8 | b[o + 2] << 16;
|
|
5
|
+
const u32be = (b, o) => b[o] * 16777216 + (b[o + 1] << 16 | b[o + 2] << 8 | b[o + 3]);
|
|
6
|
+
const ascii = (b, o, length) => {
|
|
7
|
+
let out = "";
|
|
8
|
+
for (let i = 0; i < length; i++) out += String.fromCharCode(b[o + i]);
|
|
9
|
+
return out;
|
|
10
|
+
};
|
|
11
|
+
const matches = (b, o, signature) => b.length >= o + signature.length && signature.every((byte, i) => b[o + i] === byte);
|
|
12
|
+
|
|
13
|
+
//#endregion
|
|
14
|
+
export { ascii, matches, u16be, u16le, u24le, u32be };
|