@effected/images 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/ImageBackend.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { EXTENSIONS, MIME_TYPES } from "./ImageFormat.js";
|
|
2
|
+
import { ImageBackendError } from "./ImageBackendError.js";
|
|
3
|
+
import { DIGEST_PATTERN } from "./internal/digest.js";
|
|
4
|
+
import { Context, Effect, FileSystem, Layer, Option, Path, Random } from "effect";
|
|
5
|
+
|
|
6
|
+
//#region src/ImageBackend.ts
|
|
7
|
+
/** `[extension, media type]` pairs, in the fixed order `get` probes them, from the package's one extension table. */
|
|
8
|
+
const FILES = Object.entries(EXTENSIONS).map(([format, ext]) => [ext, MIME_TYPES[format]]);
|
|
9
|
+
/** Media type to extension. Read only through `Object.hasOwn`: a content type is caller input. */
|
|
10
|
+
const EXTENSION_BY_TYPE = Object.fromEntries(FILES.map(([ext, type]) => [type, ext]));
|
|
11
|
+
const assertDigest = (key) => DIGEST_PATTERN.test(key) ? Effect.void : Effect.die(/* @__PURE__ */ new TypeError(`image backend key ${JSON.stringify(key)} is not a 64-char hex digest`));
|
|
12
|
+
/**
|
|
13
|
+
* The storage port behind `ImageCache`.
|
|
14
|
+
*
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
var ImageBackend = class ImageBackend extends Context.Service()("@effected/images/ImageBackend") {
|
|
18
|
+
/**
|
|
19
|
+
* The backend that stores nothing: `get` always misses and `set` discards the bytes.
|
|
20
|
+
*
|
|
21
|
+
* @remarks
|
|
22
|
+
* "The cache is off" as a layer, so a program keeps one `ImageCache.getOrGenerate` code path whether caching is
|
|
23
|
+
* enabled or not: provide this when it is off and `layerDirectory` (or `layerFrom`) when it is on. Every call runs
|
|
24
|
+
* the generator and returns `hit: false`; the generated bytes are still read and checked against `accept`. It needs
|
|
25
|
+
* nothing in `R`, never fails, and is a plain value, so it builds once however often it is provided.
|
|
26
|
+
*/
|
|
27
|
+
static layerNone = Layer.succeed(this)({
|
|
28
|
+
get: () => Effect.succeedNone,
|
|
29
|
+
set: () => Effect.void
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* A backend over one directory: `<directory>/<key>.<ext>`.
|
|
33
|
+
*
|
|
34
|
+
* @remarks
|
|
35
|
+
* Writes go to a temp file in the same directory and are renamed into place, so a failed or interrupted write
|
|
36
|
+
* never leaves a file that reads as a hit. Tags are accepted and ignored — a directory has no tag index. Keys must
|
|
37
|
+
* be 64-char lowercase hex digests (as `ImageCacheKey` produces); anything else is a defect, so a key can never
|
|
38
|
+
* name a path outside the directory. No expiry or eviction.
|
|
39
|
+
*
|
|
40
|
+
* Each call mints a fresh layer; bind the result to a `const` or the backend builds twice.
|
|
41
|
+
*/
|
|
42
|
+
static layerDirectory(options) {
|
|
43
|
+
return Layer.effect(ImageBackend, Effect.gen(function* () {
|
|
44
|
+
const fs = yield* FileSystem.FileSystem;
|
|
45
|
+
const path = yield* Path.Path;
|
|
46
|
+
const fileFor = (key, ext) => path.join(options.directory, `${key}.${ext}`);
|
|
47
|
+
return {
|
|
48
|
+
get: Effect.fn("ImageBackend.get")(function* (key) {
|
|
49
|
+
yield* assertDigest(key);
|
|
50
|
+
for (const [ext, contentType] of FILES) {
|
|
51
|
+
const bytes = yield* fs.readFile(fileFor(key, ext)).pipe(Effect.map(Option.some), Effect.catchReason("PlatformError", "NotFound", () => Effect.succeedNone), Effect.mapError((cause) => new ImageBackendError({
|
|
52
|
+
operation: "get",
|
|
53
|
+
key,
|
|
54
|
+
cause
|
|
55
|
+
})));
|
|
56
|
+
if (Option.isSome(bytes)) return Option.some({
|
|
57
|
+
value: bytes.value,
|
|
58
|
+
contentType
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return Option.none();
|
|
62
|
+
}),
|
|
63
|
+
set: Effect.fn("ImageBackend.set")(function* (params) {
|
|
64
|
+
yield* assertDigest(params.key);
|
|
65
|
+
const ext = params.contentType !== void 0 && Object.hasOwn(EXTENSION_BY_TYPE, params.contentType) ? EXTENSION_BY_TYPE[params.contentType] : void 0;
|
|
66
|
+
if (ext === void 0) return yield* new ImageBackendError({
|
|
67
|
+
operation: "set",
|
|
68
|
+
key: params.key,
|
|
69
|
+
cause: /* @__PURE__ */ new Error(`unsupported content type ${JSON.stringify(params.contentType ?? null)}`)
|
|
70
|
+
});
|
|
71
|
+
const target = fileFor(params.key, ext);
|
|
72
|
+
const suffix = yield* Random.nextIntBetween(0, 2147483647);
|
|
73
|
+
const temp = path.join(options.directory, `.${params.key}.${suffix.toString(36)}.tmp`);
|
|
74
|
+
yield* Effect.gen(function* () {
|
|
75
|
+
yield* fs.makeDirectory(options.directory, { recursive: true });
|
|
76
|
+
yield* fs.writeFile(temp, params.value);
|
|
77
|
+
for (const [other] of FILES) if (other !== ext) yield* fs.remove(fileFor(params.key, other), { force: true });
|
|
78
|
+
yield* fs.rename(temp, target);
|
|
79
|
+
}).pipe(Effect.onError(() => fs.remove(temp, { force: true }).pipe(Effect.ignore)), Effect.mapError((cause) => new ImageBackendError({
|
|
80
|
+
operation: "set",
|
|
81
|
+
key: params.key,
|
|
82
|
+
cause
|
|
83
|
+
})));
|
|
84
|
+
})
|
|
85
|
+
};
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Adapt any structurally matching service, `@effected/store`'s `Cache` being the intended one, as the backend.
|
|
90
|
+
*
|
|
91
|
+
* @remarks
|
|
92
|
+
* `ImageBackend.layerFrom(Cache)` gives the image cache store's TTL, eviction and tag invalidation. Every source
|
|
93
|
+
* failure is wrapped as {@link ImageBackendError} with the original as `cause`.
|
|
94
|
+
*
|
|
95
|
+
* Each call mints a fresh layer; bind the result to a `const` or the backend builds twice.
|
|
96
|
+
*/
|
|
97
|
+
static layerFrom(key) {
|
|
98
|
+
return Layer.effect(ImageBackend, Effect.map(key, (source) => ({
|
|
99
|
+
get: (k) => source.get(k).pipe(Effect.map(Option.map((entry) => ({
|
|
100
|
+
value: entry.value,
|
|
101
|
+
contentType: entry.contentType
|
|
102
|
+
}))), Effect.mapError((cause) => new ImageBackendError({
|
|
103
|
+
operation: "get",
|
|
104
|
+
key: k,
|
|
105
|
+
cause
|
|
106
|
+
})), Effect.withSpan("ImageBackend.get")),
|
|
107
|
+
set: (params) => source.set(params).pipe(Effect.mapError((cause) => new ImageBackendError({
|
|
108
|
+
operation: "set",
|
|
109
|
+
key: params.key,
|
|
110
|
+
cause
|
|
111
|
+
})), Effect.withSpan("ImageBackend.set"))
|
|
112
|
+
})));
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
//#endregion
|
|
117
|
+
export { ImageBackend };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/ImageBackendError.ts
|
|
4
|
+
const causeMessage = (cause) => {
|
|
5
|
+
let current = cause;
|
|
6
|
+
for (let depth = 0; depth < 8 && current instanceof Error && current.cause instanceof Error; depth++) current = current.cause;
|
|
7
|
+
return current instanceof Error ? current.message : String(current);
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* An image backend read or write failed.
|
|
11
|
+
*
|
|
12
|
+
* @public
|
|
13
|
+
*/
|
|
14
|
+
var ImageBackendError = class extends Schema.TaggedError()("ImageBackendError", {
|
|
15
|
+
/** The backend operation that failed. */
|
|
16
|
+
operation: Schema.Literals(["get", "set"]),
|
|
17
|
+
/** The key involved. */
|
|
18
|
+
key: Schema.String,
|
|
19
|
+
/** The underlying failure, preserved structurally. */
|
|
20
|
+
cause: Schema.Defect()
|
|
21
|
+
}) {
|
|
22
|
+
get message() {
|
|
23
|
+
return `Image backend ${this.operation} failed for key "${this.key}": ${causeMessage(this.cause)}`;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
//#endregion
|
|
28
|
+
export { ImageBackendError };
|
package/ImageCache.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { ImageFacts } from "./ImageFacts.js";
|
|
2
|
+
import { ImageBackend } from "./ImageBackend.js";
|
|
3
|
+
import { ImageGenerateError } from "./ImageGenerateError.js";
|
|
4
|
+
import { Context, Effect, Layer, Option, Result } from "effect";
|
|
5
|
+
|
|
6
|
+
//#region src/ImageCache.ts
|
|
7
|
+
/**
|
|
8
|
+
* True when `accept` admits the facts' format. With `accept` omitted the call's `F` is every format, so admitting
|
|
9
|
+
* everything is exactly the narrowing the guard claims.
|
|
10
|
+
*/
|
|
11
|
+
const accepted = (facts, accept) => accept === void 0 || accept.includes(facts.format);
|
|
12
|
+
/**
|
|
13
|
+
* A cache for generated images, keyed by `ImageCacheKey`, stored through {@link ImageBackend}.
|
|
14
|
+
*
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
var ImageCache = class ImageCache extends Context.Service()("@effected/images/ImageCache") {
|
|
18
|
+
/** The cache over whichever {@link ImageBackend} the app provides. */
|
|
19
|
+
static layer = Layer.effect(ImageCache, Effect.gen(function* () {
|
|
20
|
+
const backend = yield* ImageBackend;
|
|
21
|
+
const getOrGenerate = (key, generate, options) => Effect.gen(function* () {
|
|
22
|
+
const accept = options?.accept;
|
|
23
|
+
const stored = yield* backend.get(key.digest);
|
|
24
|
+
if (Option.isSome(stored)) {
|
|
25
|
+
const facts = ImageFacts.fromBytesResult(stored.value.value);
|
|
26
|
+
if (Result.isSuccess(facts) && accepted(facts.success, accept)) {
|
|
27
|
+
yield* Effect.annotateCurrentSpan({
|
|
28
|
+
hit: true,
|
|
29
|
+
format: facts.success.format
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
bytes: stored.value.value,
|
|
33
|
+
facts: facts.success,
|
|
34
|
+
hit: true
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
yield* Effect.annotateCurrentSpan("hit", false);
|
|
39
|
+
const bytes = yield* generate();
|
|
40
|
+
if (bytes.byteLength === 0) return yield* new ImageGenerateError({ reason: "empty" });
|
|
41
|
+
const parsed = ImageFacts.fromBytesResult(bytes);
|
|
42
|
+
if (Result.isFailure(parsed)) return yield* new ImageGenerateError({
|
|
43
|
+
reason: "unparseable",
|
|
44
|
+
cause: parsed.failure
|
|
45
|
+
});
|
|
46
|
+
const facts = parsed.success;
|
|
47
|
+
yield* Effect.annotateCurrentSpan("format", facts.format);
|
|
48
|
+
if (!accepted(facts, accept)) return yield* new ImageGenerateError({
|
|
49
|
+
reason: "rejected-format",
|
|
50
|
+
format: facts.format,
|
|
51
|
+
accept: [...accept ?? []]
|
|
52
|
+
});
|
|
53
|
+
yield* backend.set({
|
|
54
|
+
key: key.digest,
|
|
55
|
+
value: bytes,
|
|
56
|
+
contentType: facts.mimeType,
|
|
57
|
+
tags: [key.namespace]
|
|
58
|
+
});
|
|
59
|
+
return {
|
|
60
|
+
bytes,
|
|
61
|
+
facts,
|
|
62
|
+
hit: false
|
|
63
|
+
};
|
|
64
|
+
}).pipe(Effect.withSpan("ImageCache.getOrGenerate", { attributes: { namespace: key.namespace } }));
|
|
65
|
+
return { getOrGenerate };
|
|
66
|
+
}));
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
//#endregion
|
|
70
|
+
export { ImageCache };
|
package/ImageCacheKey.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { DIGEST_PATTERN } from "./internal/digest.js";
|
|
2
|
+
import { ImageCacheKeyError } from "./ImageCacheKeyError.js";
|
|
3
|
+
import { canonicalJson } from "./internal/canonical.js";
|
|
4
|
+
import { Crypto, Effect, Result, Schema } from "effect";
|
|
5
|
+
import * as Hex from "effect/encoding/Hex";
|
|
6
|
+
|
|
7
|
+
//#region src/ImageCacheKey.ts
|
|
8
|
+
/**
|
|
9
|
+
* The identity of one generated image: a SHA-256 digest of the salt and the schema-encoded parameters.
|
|
10
|
+
*
|
|
11
|
+
* @public
|
|
12
|
+
*/
|
|
13
|
+
var ImageCacheKey = class ImageCacheKey extends Schema.Class("ImageCacheKey")({
|
|
14
|
+
/** Lowercase hex SHA-256 of `salt`, a NUL, and the canonical JSON of the encoded params. */
|
|
15
|
+
digest: Schema.String.check(Schema.isPattern(DIGEST_PATTERN)),
|
|
16
|
+
/** The salt the digest was derived with. */
|
|
17
|
+
salt: Schema.String,
|
|
18
|
+
/** The grouping tag. */
|
|
19
|
+
namespace: Schema.String
|
|
20
|
+
}) {
|
|
21
|
+
/**
|
|
22
|
+
* Derive a key.
|
|
23
|
+
*
|
|
24
|
+
* @remarks
|
|
25
|
+
* `params` are encoded through `schema`, so a transformation participates and the key reflects the wire form. The
|
|
26
|
+
* digest is core `Crypto`'s SHA-256, so `Crypto` is required — Node apps get it from `NodeServices.layer` or
|
|
27
|
+
* `NodeCrypto.layer`.
|
|
28
|
+
*/
|
|
29
|
+
static fromParams(schema, params, options) {
|
|
30
|
+
return Effect.gen(function* () {
|
|
31
|
+
const encoded = yield* Schema.encodeEffect(schema)(params).pipe(Effect.mapError((cause) => new ImageCacheKeyError({
|
|
32
|
+
reason: "encode",
|
|
33
|
+
detail: cause.message,
|
|
34
|
+
cause
|
|
35
|
+
})));
|
|
36
|
+
const canonical = canonicalJson(encoded);
|
|
37
|
+
if (Result.isFailure(canonical)) return yield* new ImageCacheKeyError({
|
|
38
|
+
reason: "non-json",
|
|
39
|
+
detail: canonical.failure
|
|
40
|
+
});
|
|
41
|
+
const bytes = yield* (yield* Crypto.Crypto).digest("SHA-256", new TextEncoder().encode(`${options.salt}\u0000${canonical.success}`)).pipe(Effect.mapError((cause) => new ImageCacheKeyError({
|
|
42
|
+
reason: "digest",
|
|
43
|
+
detail: cause.message,
|
|
44
|
+
cause
|
|
45
|
+
})));
|
|
46
|
+
return ImageCacheKey.make({
|
|
47
|
+
digest: Hex.encode(bytes),
|
|
48
|
+
salt: options.salt,
|
|
49
|
+
namespace: options.namespace
|
|
50
|
+
});
|
|
51
|
+
}).pipe(Effect.withSpan("ImageCacheKey.fromParams", { attributes: { namespace: options.namespace } }));
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
//#endregion
|
|
56
|
+
export { ImageCacheKey };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/ImageCacheKeyError.ts
|
|
4
|
+
/**
|
|
5
|
+
* Cache-key parameters that could not be turned into a key.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* `encode`: the parameter schema rejected the params. `non-json`: the encoded form holds a value canonical JSON
|
|
9
|
+
* cannot carry (a `Date`, a `bigint`, a non-finite number, a class instance). `digest`: the `Crypto` service failed.
|
|
10
|
+
*
|
|
11
|
+
* @public
|
|
12
|
+
*/
|
|
13
|
+
var ImageCacheKeyError = class extends Schema.TaggedError()("ImageCacheKeyError", {
|
|
14
|
+
/** Which step failed. */
|
|
15
|
+
reason: Schema.Literals([
|
|
16
|
+
"encode",
|
|
17
|
+
"non-json",
|
|
18
|
+
"digest"
|
|
19
|
+
]),
|
|
20
|
+
/** What exactly was wrong. */
|
|
21
|
+
detail: Schema.String,
|
|
22
|
+
/** The schema error (`encode`) or platform error (`digest`). */
|
|
23
|
+
cause: Schema.optionalKey(Schema.Defect())
|
|
24
|
+
}) {
|
|
25
|
+
get message() {
|
|
26
|
+
return `Cannot derive an image cache key (${this.reason}): ${this.detail}`;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
//#endregion
|
|
31
|
+
export { ImageCacheKeyError };
|
package/ImageFacts.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { EXTENSIONS, ImageFormat, ImageMimeTypeSchema, MIME_TYPES } from "./ImageFormat.js";
|
|
2
|
+
import { ImageParseError } from "./ImageParseError.js";
|
|
3
|
+
import { readAvif } from "./internal/avif.js";
|
|
4
|
+
import { detectFormat } from "./internal/detect.js";
|
|
5
|
+
import { readGif } from "./internal/gif.js";
|
|
6
|
+
import { readJpeg } from "./internal/jpeg.js";
|
|
7
|
+
import { readPng } from "./internal/png.js";
|
|
8
|
+
import { readWebp } from "./internal/webp.js";
|
|
9
|
+
import { Effect, Result, Schema } from "effect";
|
|
10
|
+
|
|
11
|
+
//#region src/ImageFacts.ts
|
|
12
|
+
const READERS = {
|
|
13
|
+
png: readPng,
|
|
14
|
+
jpeg: readJpeg,
|
|
15
|
+
gif: readGif,
|
|
16
|
+
webp: readWebp,
|
|
17
|
+
avif: readAvif
|
|
18
|
+
};
|
|
19
|
+
const Dimension = Schema.Int.check(Schema.isGreaterThan(0));
|
|
20
|
+
/**
|
|
21
|
+
* What an image's header says about it: format, MIME type and stored pixel dimensions.
|
|
22
|
+
*
|
|
23
|
+
* @remarks
|
|
24
|
+
* Dimensions are as stored in the file; EXIF orientation is not applied.
|
|
25
|
+
*
|
|
26
|
+
* @public
|
|
27
|
+
*/
|
|
28
|
+
var ImageFacts = class ImageFacts extends Schema.Class("ImageFacts")({
|
|
29
|
+
/** The detected format. */
|
|
30
|
+
format: ImageFormat,
|
|
31
|
+
/** The IANA media type for `format`: one of `image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/avif`. */
|
|
32
|
+
mimeType: ImageMimeTypeSchema,
|
|
33
|
+
/** Stored pixel width. */
|
|
34
|
+
width: Dimension,
|
|
35
|
+
/** Stored pixel height. */
|
|
36
|
+
height: Dimension
|
|
37
|
+
}) {
|
|
38
|
+
/**
|
|
39
|
+
* The conventional file extension for `format`, without the dot: `jpg` for `jpeg`, the format name otherwise.
|
|
40
|
+
*
|
|
41
|
+
* @remarks
|
|
42
|
+
* A getter, not a field: it is derived from `format` on read, so it is absent from the encoded form and a decoded
|
|
43
|
+
* value has it again. Name a written file with it instead of keeping a private format-to-extension table.
|
|
44
|
+
*/
|
|
45
|
+
get extension() {
|
|
46
|
+
return EXTENSIONS[this.format];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Read facts from an image's bytes — the synchronous primitive.
|
|
50
|
+
*
|
|
51
|
+
* @remarks
|
|
52
|
+
* Reads only the header. Never throws: unknown bytes are `unrecognized`, a
|
|
53
|
+
* header that ends early is `truncated`, an impossible header is `malformed`.
|
|
54
|
+
*/
|
|
55
|
+
static fromBytesResult(bytes) {
|
|
56
|
+
const format = detectFormat(bytes);
|
|
57
|
+
if (format === void 0) return Result.fail(new ImageParseError({ reason: "unrecognized" }));
|
|
58
|
+
const read = READERS[format](bytes);
|
|
59
|
+
if (Result.isFailure(read)) return Result.fail(new ImageParseError({
|
|
60
|
+
reason: read.failure.reason,
|
|
61
|
+
format,
|
|
62
|
+
detail: read.failure.detail
|
|
63
|
+
}));
|
|
64
|
+
return Result.succeed(ImageFacts.make({
|
|
65
|
+
format,
|
|
66
|
+
mimeType: MIME_TYPES[format],
|
|
67
|
+
width: read.success.width,
|
|
68
|
+
height: read.success.height
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Read facts from an image's bytes — {@link ImageFacts.fromBytesResult} with a span.
|
|
73
|
+
*/
|
|
74
|
+
static fromBytes = Effect.fn("ImageFacts.fromBytes")((bytes) => Effect.fromResult(ImageFacts.fromBytesResult(bytes)));
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
//#endregion
|
|
78
|
+
export { ImageFacts };
|
package/ImageFormat.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/ImageFormat.ts
|
|
4
|
+
/**
|
|
5
|
+
* The image formats `@effected/images` reads.
|
|
6
|
+
*
|
|
7
|
+
* @public
|
|
8
|
+
*/
|
|
9
|
+
const ImageFormat = Schema.Literals([
|
|
10
|
+
"png",
|
|
11
|
+
"jpeg",
|
|
12
|
+
"gif",
|
|
13
|
+
"webp",
|
|
14
|
+
"avif"
|
|
15
|
+
]);
|
|
16
|
+
/**
|
|
17
|
+
* The conventional file extension for each format — the one table every extension in the package is read from.
|
|
18
|
+
* `ImageFacts#extension` declares the union these values must fall in.
|
|
19
|
+
*
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
const EXTENSIONS = {
|
|
23
|
+
png: "png",
|
|
24
|
+
jpeg: "jpg",
|
|
25
|
+
gif: "gif",
|
|
26
|
+
webp: "webp",
|
|
27
|
+
avif: "avif"
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* The IANA media type for each format. `ImageFacts.mimeType`'s schema is built from these values.
|
|
31
|
+
*
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
const MIME_TYPES = {
|
|
35
|
+
png: "image/png",
|
|
36
|
+
jpeg: "image/jpeg",
|
|
37
|
+
gif: "image/gif",
|
|
38
|
+
webp: "image/webp",
|
|
39
|
+
avif: "image/avif"
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* The schema of `ImageFacts.mimeType`: the literals of {@link MIME_TYPES}, so the two cannot drift.
|
|
43
|
+
*
|
|
44
|
+
* @internal
|
|
45
|
+
*/
|
|
46
|
+
const ImageMimeTypeSchema = Schema.Literals([
|
|
47
|
+
MIME_TYPES.png,
|
|
48
|
+
MIME_TYPES.jpeg,
|
|
49
|
+
MIME_TYPES.gif,
|
|
50
|
+
MIME_TYPES.webp,
|
|
51
|
+
MIME_TYPES.avif
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
//#endregion
|
|
55
|
+
export { EXTENSIONS, ImageFormat, ImageMimeTypeSchema, MIME_TYPES };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ImageFormat } from "./ImageFormat.js";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/ImageGenerateError.ts
|
|
5
|
+
/**
|
|
6
|
+
* A generator produced bytes the cache will not store.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* `empty`: zero bytes. `unparseable`: not a readable image (`cause` is the `ImageParseError`).
|
|
10
|
+
* `rejected-format`: a readable image whose format is outside the caller's `accept` list.
|
|
11
|
+
*
|
|
12
|
+
* @public
|
|
13
|
+
*/
|
|
14
|
+
var ImageGenerateError = class extends Schema.TaggedError()("ImageGenerateError", {
|
|
15
|
+
/** Why the output was refused. */
|
|
16
|
+
reason: Schema.Literals([
|
|
17
|
+
"empty",
|
|
18
|
+
"unparseable",
|
|
19
|
+
"rejected-format"
|
|
20
|
+
]),
|
|
21
|
+
/** The output's format, when it was readable. */
|
|
22
|
+
format: Schema.optionalKey(ImageFormat),
|
|
23
|
+
/** The formats the caller accepts, for `rejected-format`. */
|
|
24
|
+
accept: Schema.optionalKey(Schema.Array(ImageFormat)),
|
|
25
|
+
/** The parse failure, for `unparseable`. */
|
|
26
|
+
cause: Schema.optionalKey(Schema.Defect())
|
|
27
|
+
}) {
|
|
28
|
+
get message() {
|
|
29
|
+
return this.reason === "empty" ? "Image generator returned no bytes" : this.reason === "unparseable" ? "Image generator returned bytes that are not a readable image" : `Image generator returned a ${this.format ?? "?"} image; accepted: ${(this.accept ?? []).join(", ")}`;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
//#endregion
|
|
34
|
+
export { ImageGenerateError };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { ImageFormat } from "./ImageFormat.js";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/ImageParseError.ts
|
|
5
|
+
/**
|
|
6
|
+
* Bytes that could not be read as an image header.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* `unrecognized`: no known signature. `truncated`: a signature matched but the
|
|
10
|
+
* bytes end before the dimension fields. `malformed`: a header field is
|
|
11
|
+
* impossible (a zero or out-of-range dimension, an inconsistent length, or a
|
|
12
|
+
* walk that exhausted its step budget).
|
|
13
|
+
*
|
|
14
|
+
* @public
|
|
15
|
+
*/
|
|
16
|
+
var ImageParseError = class extends Schema.TaggedError()("ImageParseError", {
|
|
17
|
+
/** Why the header could not be read. */
|
|
18
|
+
reason: Schema.Literals([
|
|
19
|
+
"unrecognized",
|
|
20
|
+
"truncated",
|
|
21
|
+
"malformed"
|
|
22
|
+
]),
|
|
23
|
+
/** The format whose signature matched, when one did. */
|
|
24
|
+
format: Schema.optionalKey(ImageFormat),
|
|
25
|
+
/** What exactly was wrong. */
|
|
26
|
+
detail: Schema.optionalKey(Schema.String)
|
|
27
|
+
}) {
|
|
28
|
+
get message() {
|
|
29
|
+
const lead = this.reason === "unrecognized" ? "Unrecognized image signature" : `${this.reason === "truncated" ? "Truncated" : "Malformed"} ${this.format ?? "image"} header`;
|
|
30
|
+
return this.detail === void 0 ? lead : `${lead}: ${this.detail}`;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
//#endregion
|
|
35
|
+
export { ImageParseError };
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 C. Spencer Beggs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|