image-renditions 0.2.0 → 0.4.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 +25 -6
- package/dist/h3.d.mts +1 -1
- package/dist/h3.mjs +1 -1
- package/dist/{handler-CPJllrYi.mjs → handler-D5toFJ55.mjs} +1 -1
- package/dist/{handler-9XP5SZhg.d.mts → handler-P49xsp3S.d.mts} +18 -3
- package/dist/hash-CHipV1N6.mjs +16 -0
- package/dist/index.d.mts +2 -4
- package/dist/index.mjs +8 -3
- package/dist/{processor-DvyPs-OZ.d.mts → processor-Dd3jqaOW.d.mts} +18 -2
- package/dist/server.d.mts +3 -3
- package/dist/server.mjs +28 -15
- package/dist/sharp.d.mts +1 -1
- package/dist/sharp.mjs +27 -7
- package/dist/{url-BAU5GYXC.mjs → url-C6PtYIEf.mjs} +11 -15
- package/dist/{url-BVH3ZB0F.d.mts → url-DoJdUx9J.d.mts} +1 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,10 +8,10 @@ Resize and re-encode uploaded images when a page first shows them, store each re
|
|
|
8
8
|
|
|
9
9
|
```html
|
|
10
10
|
<img
|
|
11
|
-
src="/_renditions/v1/avatar.
|
|
11
|
+
src="/_renditions/v1/avatar.faf7e221f65d.avif/avatars/42.jpg"
|
|
12
12
|
srcset="
|
|
13
|
-
/_renditions/v1/avatar.
|
|
14
|
-
/_renditions/v1/avatar@2x.
|
|
13
|
+
/_renditions/v1/avatar.faf7e221f65d.avif/avatars/42.jpg 1x,
|
|
14
|
+
/_renditions/v1/avatar@2x.b62b90cf6c3e.avif/avatars/42.jpg 2x
|
|
15
15
|
"
|
|
16
16
|
alt=""
|
|
17
17
|
/>
|
|
@@ -23,7 +23,13 @@ Resize and re-encode uploaded images when a page first shows them, store each re
|
|
|
23
23
|
|
|
24
24
|
With an S3 bucket, or a directory that outlives deploys and is shared by every replica, app servers keep no image files: renditions survive redeploys, and any replica serves any of them.
|
|
25
25
|
|
|
26
|
-
The
|
|
26
|
+
The library is modular, with pluggable parts:
|
|
27
|
+
|
|
28
|
+
- [HTTP](#h3-and-other-frameworks): an event handler for h3 1.15 or newer and h3 2, and a framework-neutral handler for other servers.
|
|
29
|
+
- [Processing](#processing): a processor built on sharp.
|
|
30
|
+
- [Storage](#storage): an S3 bucket or a file system directory, chosen separately for uploads and renditions.
|
|
31
|
+
- [Nuxt](#quick-start): a Nuxt 4 module that registers the handler and the `<RenditionImg>` component.
|
|
32
|
+
- [Extending](#extending): other storage, image libraries, and state shared between replicas plug in through the interfaces of `image-renditions/server`.
|
|
27
33
|
|
|
28
34
|
## When not to use it
|
|
29
35
|
|
|
@@ -232,7 +238,7 @@ export default defineRenditionEventHandler({
|
|
|
232
238
|
- Map different scopes to different keys even for equal paths.
|
|
233
239
|
- A rendition is stored in the result store at `<sourceKey>/<hash>.<extension>`.
|
|
234
240
|
To keep renditions in the bucket of uploads, also return a `resultPrefix` that no source key starts with, such as `` `renditions/${sourceKey}` ``, to use instead of `sourceKey`.
|
|
235
|
-
- S3 limits a key to 1024 bytes in UTF-8, and a rendition key
|
|
241
|
+
- S3 limits a key to 1024 bytes in UTF-8, and the library writes a rendition under a key up to 18 bytes longer than its result prefix.
|
|
236
242
|
If source keys can come that close to the limit, return a shorter `resultPrefix`, or their renditions fail as `invalid-request`.
|
|
237
243
|
|
|
238
244
|
Derive the context from the URL only, host included, for example from the tenant's domain: browsers and CDNs cache the redirect to a rendition by its URL for a day.
|
|
@@ -403,6 +409,18 @@ It renders the first frame of other animated sources, such as APNG and animated
|
|
|
403
409
|
An animation at the pixel limit may take about 4 bytes of memory per pixel, and its encoder needs more, so plan memory for `limits.concurrency` such renditions at once.
|
|
404
410
|
Changing the limits keeps stored renditions as they are; bump `pipelineVersion` to render them again.
|
|
405
411
|
|
|
412
|
+
When a preset keeps the animation and a GIF or WebP animation renders its first frame because of its EXIF orientation or a limit, `createRenditionServer()` passes it to `onDegradation` once the rendition is written.
|
|
413
|
+
The reason is `animation-orientation`, `animation-frames` or `animation-pixels`, and the message has the values involved, so you can log such renditions and tune the limits:
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
const server = createRenditionServer({
|
|
417
|
+
// ...
|
|
418
|
+
onDegradation({ message, target }) {
|
|
419
|
+
console.warn(`The rendition of ${target.path} is degraded: ${message}`)
|
|
420
|
+
},
|
|
421
|
+
})
|
|
422
|
+
```
|
|
423
|
+
|
|
406
424
|
To process images with another library, implement the [`Processor`](#extending) interface and pass your implementation to `createRenditionServer()` as `processor`.
|
|
407
425
|
|
|
408
426
|
## h3 and other frameworks
|
|
@@ -443,7 +461,7 @@ Build URLs in the app with the catalog, which is safe to import in browsers:
|
|
|
443
461
|
|
|
444
462
|
```ts
|
|
445
463
|
catalog.url("avatar", { path: "avatars/42.jpg" })
|
|
446
|
-
// /_renditions/v1/avatar.
|
|
464
|
+
// /_renditions/v1/avatar.faf7e221f65d.avif/avatars/42.jpg
|
|
447
465
|
|
|
448
466
|
catalog.srcset("avatar", { scope: "tenant", path: "avatars/42.jpg" })
|
|
449
467
|
```
|
|
@@ -570,6 +588,7 @@ Implement an interface from `image-renditions/server` and pass your implementati
|
|
|
570
588
|
- To use other storage, implement `SourceStore` or `ResultStore` and pass it as `source` or `results`.
|
|
571
589
|
- To process images with another library, implement `Processor` and pass it as `processor`.
|
|
572
590
|
Implement its `assertRecipe()` to reject recipes it cannot render, such as animations, when the server is created.
|
|
591
|
+
Return `degradation` from its `process()` to report an image that falls short of its recipe to `onDegradation`.
|
|
573
592
|
- To share the index of published renditions between replicas, for example in Redis, implement `ReadinessIndex` and pass it as `readiness.shared`.
|
|
574
593
|
If a call fails or exceeds `indexTimeout`, the server reports the error to `onError` and checks storage instead, while [outdated URLs](#outdated-urls) fail as `unknown-recipe`.
|
|
575
594
|
- To render each rendition once across replicas, implement `Coordinator` as a shared lock and pass it as `coordinator`.
|
package/dist/h3.d.mts
CHANGED
package/dist/h3.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as parseRenditionPath } from "./url-BAU5GYXC.mjs";
|
|
2
1
|
import { r as toDeclaredTarget } from "./scopes-C1kzqQHz.mjs";
|
|
2
|
+
import { i as parseRenditionPath } from "./url-C6PtYIEf.mjs";
|
|
3
3
|
import { t as RenditionError } from "./error-DViVpVmH.mjs";
|
|
4
4
|
//#region src/server/handler.ts
|
|
5
5
|
const failureStatus = {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { r as Recipe, s as RenditionFormat } from "./recipe-BT8D60Qb.mjs";
|
|
2
2
|
import { a as RenditionTarget, t as Catalog } from "./catalog-D11xkViN.mjs";
|
|
3
|
-
import { n as RenditionAddress } from "./url-
|
|
3
|
+
import { n as RenditionAddress } from "./url-DoJdUx9J.mjs";
|
|
4
4
|
import { i as StoreCallOptions, n as ResultStore, r as SourceStore } from "./stores-P-TUmax4.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { r as Processor, t as Degradation } from "./processor-Dd3jqaOW.mjs";
|
|
6
6
|
//#region src/server/error.d.ts
|
|
7
7
|
/** Why a rendition could not be served. */
|
|
8
8
|
type RenditionFailureReason =
|
|
@@ -124,6 +124,21 @@ interface RenditionServerOptions<Context, Scope extends string = string> {
|
|
|
124
124
|
* It is not awaited, and its own failures are ignored.
|
|
125
125
|
*/
|
|
126
126
|
onError?(error: unknown): void;
|
|
127
|
+
/**
|
|
128
|
+
* Receives a degradation the processor reports for a rendition it rendered, such as the first frame of an animation beyond a limit, once the rendition is written.
|
|
129
|
+
*
|
|
130
|
+
* Later requests for the rendition do not report it again.
|
|
131
|
+
* It is not awaited, and its own failures are ignored.
|
|
132
|
+
*/
|
|
133
|
+
onDegradation?(degradation: RenditionDegradation<Context, Scope>): void;
|
|
134
|
+
}
|
|
135
|
+
/** Degradation a processor reported for a rendition the server wrote. */
|
|
136
|
+
interface RenditionDegradation<Context, Scope extends string = string> extends Degradation {
|
|
137
|
+
recipe: Recipe;
|
|
138
|
+
target: RenditionTarget<Scope>;
|
|
139
|
+
/** Context of the call that rendered the rendition, which may be any of the concurrent calls for it. */
|
|
140
|
+
context: Context;
|
|
141
|
+
rendition: Rendition;
|
|
127
142
|
}
|
|
128
143
|
/** Published rendition. */
|
|
129
144
|
interface Rendition {
|
|
@@ -224,4 +239,4 @@ type RenditionHandler<Context> = (request: RenditionRequest, getContext: () => C
|
|
|
224
239
|
*/
|
|
225
240
|
declare function createRenditionHandler<Context, Scope extends string = string>({ server, onFailure, redirectCacheControl }: RenditionHandlerOptions<Context, Scope>): RenditionHandler<Context>;
|
|
226
241
|
//#endregion
|
|
227
|
-
export {
|
|
242
|
+
export { RenditionError as _, createRenditionHandler as a, RenditionServer as c, createRenditionServer as d, MemoryReadinessIndexOptions as f, createLocalCoordinator as g, Coordinator as h, RenditionRequest as i, RenditionServerOptions as l, createMemoryReadinessIndex as m, RenditionHandler as n, Rendition as o, ReadinessIndex as p, RenditionHandlerOptions as r, RenditionDegradation as s, RenditionFailure as t, SourceResolution as u, RenditionFailureReason as v };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { o as serializeRecipe } from "./recipe-BavgOq0b.mjs";
|
|
2
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
3
|
+
import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
|
|
4
|
+
//#region src/hash.ts
|
|
5
|
+
/**
|
|
6
|
+
* Length of a recipe hash in hex digits, which keeps 48 bits of SHA-256.
|
|
7
|
+
*
|
|
8
|
+
* It tells apart the few thousand recipes an application may have over its lifetime, and a catalog rejects a collision among its own recipes.
|
|
9
|
+
*/
|
|
10
|
+
const RECIPE_HASH_LENGTH = 12;
|
|
11
|
+
/** Identifies a recipe by the truncated SHA-256 of its canonical serialization. */
|
|
12
|
+
function hashRecipe(recipe) {
|
|
13
|
+
return bytesToHex(sha256(utf8ToBytes(serializeRecipe(recipe)))).slice(0, RECIPE_HASH_LENGTH);
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
export { hashRecipe as t };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { a as RenditionAnimation, c as RenditionOptions, d as formatContentTypes, f as formatExtensions, i as RecipeInput, l as RenditionPosition, m as serializeRecipe, n as RECIPE_ENGINE_VERSION, o as RenditionFit, p as normalizeRecipe, r as Recipe, s as RenditionFormat, t as MAX_DIMENSION, u as SourcePolicy } from "./recipe-BT8D60Qb.mjs";
|
|
2
2
|
import { a as RenditionTarget, c as defineCatalog, i as PresetName, n as CatalogDefinition, o as RenditionVariant, r as PresetDefinition, s as ScopeName, t as Catalog } from "./catalog-D11xkViN.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { i as parseRenditionPath, n as RenditionAddress, r as formatRenditionPath, t as DEFAULT_BASE_PATH } from "./url-DoJdUx9J.mjs";
|
|
4
4
|
//#region src/hash.d.ts
|
|
5
|
-
/** Length of a recipe hash in hex digits, which keeps 128 bits of SHA-256. */
|
|
6
|
-
export declare const RECIPE_HASH_LENGTH = 32;
|
|
7
5
|
/** Identifies a recipe by the truncated SHA-256 of its canonical serialization. */
|
|
8
6
|
export declare function hashRecipe(recipe: Recipe): string;
|
|
9
7
|
//#endregion
|
|
@@ -20,4 +18,4 @@ export declare function encodePath(path: string): string;
|
|
|
20
18
|
/** Checks that a scope is a short token that needs no URL encoding. */
|
|
21
19
|
export declare function isSafeScope(scope: string): boolean;
|
|
22
20
|
//#endregion
|
|
23
|
-
export { Catalog, CatalogDefinition, DEFAULT_BASE_PATH, MAX_DIMENSION, PresetDefinition, PresetName, RECIPE_ENGINE_VERSION, Recipe, RecipeInput, RenditionAddress, RenditionAnimation, RenditionFit, RenditionFormat, RenditionOptions, RenditionPosition, RenditionTarget, RenditionVariant, ScopeName, SourcePolicy,
|
|
21
|
+
export { Catalog, CatalogDefinition, DEFAULT_BASE_PATH, MAX_DIMENSION, PresetDefinition, PresetName, RECIPE_ENGINE_VERSION, Recipe, RecipeInput, type RenditionAddress, RenditionAnimation, RenditionFit, RenditionFormat, RenditionOptions, RenditionPosition, RenditionTarget, RenditionVariant, ScopeName, SourcePolicy, defineCatalog, formatContentTypes, formatExtensions, formatRenditionPath, normalizeRecipe, parseRenditionPath, serializeRecipe };
|
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { a as normalizeRecipe, i as formatExtensions, n as RECIPE_ENGINE_VERSION, o as serializeRecipe, r as formatContentTypes, t as MAX_DIMENSION } from "./recipe-BavgOq0b.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { t as hashRecipe } from "./hash-CHipV1N6.mjs";
|
|
3
3
|
import { n as isSafePath, r as isSafeScope, t as encodePath } from "./path-hNFUjCbA.mjs";
|
|
4
4
|
import { t as assertDeclaredScope } from "./scopes-C1kzqQHz.mjs";
|
|
5
|
+
import { i as parseRenditionPath, n as assertBasePath, r as formatRenditionPath, t as DEFAULT_BASE_PATH } from "./url-C6PtYIEf.mjs";
|
|
5
6
|
//#region src/catalog.ts
|
|
6
7
|
/** Extensions of sources that get animated variants, as a path does not tell an animated source from a still one. */
|
|
7
8
|
const ANIMATED_EXTENSIONS = ["gif", "webp"];
|
|
@@ -64,7 +65,11 @@ function defineCatalog(definition) {
|
|
|
64
65
|
originals: Object.freeze([...originals]),
|
|
65
66
|
animatedVariants
|
|
66
67
|
});
|
|
67
|
-
for (const variant of [...variants, ...animatedVariants ?? []])
|
|
68
|
+
for (const variant of [...variants, ...animatedVariants ?? []]) {
|
|
69
|
+
const known = variantsByHash.get(variant.hash);
|
|
70
|
+
if (known && serializeRecipe(known.recipe) !== serializeRecipe(variant.recipe)) throw new RangeError(`Variants "${known.label}" and "${variant.label}" have different recipes of the same hash; change pipelineVersion to change every hash.`);
|
|
71
|
+
if (!known) variantsByHash.set(variant.hash, variant);
|
|
72
|
+
}
|
|
68
73
|
}
|
|
69
74
|
function getPreset(preset) {
|
|
70
75
|
const found = presetsByName.get(preset);
|
|
@@ -120,4 +125,4 @@ function wrapRangeError(context, fn) {
|
|
|
120
125
|
}
|
|
121
126
|
}
|
|
122
127
|
//#endregion
|
|
123
|
-
export { DEFAULT_BASE_PATH, MAX_DIMENSION, RECIPE_ENGINE_VERSION,
|
|
128
|
+
export { DEFAULT_BASE_PATH, MAX_DIMENSION, RECIPE_ENGINE_VERSION, defineCatalog, encodePath, formatContentTypes, formatExtensions, formatRenditionPath, hashRecipe, isSafePath, isSafeScope, normalizeRecipe, parseRenditionPath, serializeRecipe };
|
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
import { r as Recipe } from "./recipe-BT8D60Qb.mjs";
|
|
2
2
|
import { t as EncodedImage } from "./stores-P-TUmax4.mjs";
|
|
3
3
|
//#region src/server/processor.d.ts
|
|
4
|
+
/** Shortfall of a rendered image against its recipe, which a processor reports. */
|
|
5
|
+
interface Degradation {
|
|
6
|
+
/** Code of the shortfall, such as `animation-frames`. */
|
|
7
|
+
reason: string;
|
|
8
|
+
/** Human-readable description with the values involved, such as an exceeded limit. */
|
|
9
|
+
message: string;
|
|
10
|
+
}
|
|
11
|
+
/** Image a processor renders. */
|
|
12
|
+
interface ProcessedImage extends EncodedImage {
|
|
13
|
+
/**
|
|
14
|
+
* Shortfall the processor detected, such as the first frame of an animation beyond a limit.
|
|
15
|
+
*
|
|
16
|
+
* Its absence does not promise that the image renders the recipe in full.
|
|
17
|
+
*/
|
|
18
|
+
degradation?: Degradation;
|
|
19
|
+
}
|
|
4
20
|
/** Renders a source image according to a recipe. */
|
|
5
21
|
interface Processor {
|
|
6
22
|
/**
|
|
@@ -8,7 +24,7 @@ interface Processor {
|
|
|
8
24
|
*
|
|
9
25
|
* Rejects with a `RenditionError` of reason `unsupported-source` when the source cannot be decoded or processed, and of reason `source-too-large` when it exceeds a size limit.
|
|
10
26
|
*/
|
|
11
|
-
process(source: Uint8Array, recipe: Recipe): Promise<
|
|
27
|
+
process(source: Uint8Array, recipe: Recipe): Promise<ProcessedImage>;
|
|
12
28
|
/**
|
|
13
29
|
* Checks that the processor renders a recipe as it describes.
|
|
14
30
|
*
|
|
@@ -19,4 +35,4 @@ interface Processor {
|
|
|
19
35
|
assertRecipe?(recipe: Recipe): void;
|
|
20
36
|
}
|
|
21
37
|
//#endregion
|
|
22
|
-
export { Processor as t };
|
|
38
|
+
export { ProcessedImage as n, Processor as r, Degradation as t };
|
package/dist/server.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { _ as
|
|
1
|
+
import { _ as RenditionError, a as createRenditionHandler, c as RenditionServer, d as createRenditionServer, f as MemoryReadinessIndexOptions, g as createLocalCoordinator, h as Coordinator, i as RenditionRequest, l as RenditionServerOptions, m as createMemoryReadinessIndex, n as RenditionHandler, o as Rendition, p as ReadinessIndex, r as RenditionHandlerOptions, s as RenditionDegradation, t as RenditionFailure, u as SourceResolution, v as RenditionFailureReason } from "./handler-P49xsp3S.mjs";
|
|
2
2
|
import { i as StoreCallOptions, n as ResultStore, r as SourceStore, t as EncodedImage } from "./stores-P-TUmax4.mjs";
|
|
3
|
-
import {
|
|
4
|
-
export { Coordinator, EncodedImage, MemoryReadinessIndexOptions, Processor, ReadinessIndex, Rendition, RenditionError, RenditionFailure, RenditionFailureReason, RenditionHandler, RenditionHandlerOptions, RenditionRequest, RenditionServer, RenditionServerOptions, ResultStore, SourceResolution, SourceStore, StoreCallOptions, createLocalCoordinator, createMemoryReadinessIndex, createRenditionHandler, createRenditionServer };
|
|
3
|
+
import { n as ProcessedImage, r as Processor, t as Degradation } from "./processor-Dd3jqaOW.mjs";
|
|
4
|
+
export { Coordinator, Degradation, EncodedImage, MemoryReadinessIndexOptions, ProcessedImage, Processor, ReadinessIndex, Rendition, RenditionDegradation, RenditionError, RenditionFailure, RenditionFailureReason, RenditionHandler, RenditionHandlerOptions, RenditionRequest, RenditionServer, RenditionServerOptions, ResultStore, SourceResolution, SourceStore, StoreCallOptions, createLocalCoordinator, createMemoryReadinessIndex, createRenditionHandler, createRenditionServer };
|
package/dist/server.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { i as formatExtensions, s as assertInteger } from "./recipe-BavgOq0b.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { t as hashRecipe } from "./hash-CHipV1N6.mjs";
|
|
3
3
|
import { n as isSafePath } from "./path-hNFUjCbA.mjs";
|
|
4
4
|
import { n as isDeclaredScope } from "./scopes-C1kzqQHz.mjs";
|
|
5
5
|
import { t as RenditionError } from "./error-DViVpVmH.mjs";
|
|
6
|
-
import { t as createRenditionHandler } from "./handler-
|
|
6
|
+
import { t as createRenditionHandler } from "./handler-D5toFJ55.mjs";
|
|
7
7
|
//#region src/server/coordinator.ts
|
|
8
8
|
/** Creates a coordinator that merges concurrent equal requests within the process. */
|
|
9
9
|
function createLocalCoordinator() {
|
|
@@ -88,7 +88,7 @@ const MAX_TIMEOUT = 2 ** 31 - 1;
|
|
|
88
88
|
* @throws {RangeError} When a limit is not an integer in its range, or the processor cannot render a variant of the catalog.
|
|
89
89
|
*/
|
|
90
90
|
function createRenditionServer(options) {
|
|
91
|
-
const { catalog, source, results, processor, resolveSource = resolveUnscoped, onError } = options;
|
|
91
|
+
const { catalog, source, results, processor, resolveSource = resolveUnscoped, onError, onDegradation } = options;
|
|
92
92
|
const memory = createMemoryReadinessIndex(options.readiness?.memory);
|
|
93
93
|
const shared = options.readiness?.shared;
|
|
94
94
|
const coordinator = options.coordinator ?? createLocalCoordinator();
|
|
@@ -148,28 +148,26 @@ function createRenditionServer(options) {
|
|
|
148
148
|
try {
|
|
149
149
|
return await withTimeout(indexTimeout, call);
|
|
150
150
|
} catch (error) {
|
|
151
|
-
|
|
151
|
+
notify(onError, error);
|
|
152
152
|
return;
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
|
-
/** Passes an error to `onError` without waiting for it, so a failing or hanging callback cannot affect the operation. */
|
|
156
|
-
function report(error) {
|
|
157
|
-
try {
|
|
158
|
-
Promise.resolve(onError?.(error)).catch(() => {});
|
|
159
|
-
} catch {}
|
|
160
|
-
}
|
|
161
155
|
function callStorage(reason, call) {
|
|
162
156
|
return withTimeout(storageTimeout, call).catch((error) => {
|
|
163
157
|
throw error instanceof RenditionError ? error : new RenditionError(reason, `Storage call failed: ${error}`, { cause: error });
|
|
164
158
|
});
|
|
165
159
|
}
|
|
166
160
|
async function render(recipe, sourceKey, key) {
|
|
167
|
-
const
|
|
168
|
-
if (!
|
|
169
|
-
const
|
|
161
|
+
const input = await callStorage("source-error", (signal) => source.read(sourceKey, { signal }));
|
|
162
|
+
if (!input) throw new RenditionError("source-not-found", `The source ${sourceKey} does not exist.`);
|
|
163
|
+
const { data, contentType, degradation } = await processor.process(input, recipe).catch((error) => {
|
|
170
164
|
throw error instanceof RenditionError ? error : new RenditionError("processing-failed", `Processing failed: ${error}`, { cause: error });
|
|
171
165
|
});
|
|
172
|
-
await callStorage("storage-error", (signal) => results.write(key,
|
|
166
|
+
await callStorage("storage-error", (signal) => results.write(key, {
|
|
167
|
+
data,
|
|
168
|
+
contentType
|
|
169
|
+
}, { signal }));
|
|
170
|
+
return degradation;
|
|
173
171
|
}
|
|
174
172
|
return {
|
|
175
173
|
catalog,
|
|
@@ -180,7 +178,16 @@ function createRenditionServer(options) {
|
|
|
180
178
|
if (await isIndexed(rendition.url)) return rendition;
|
|
181
179
|
return coordinator.run(rendition.url, async () => {
|
|
182
180
|
if (!await isIndexed(rendition.url)) {
|
|
183
|
-
if (!await callStorage("storage-error", (signal) => results.exists(rendition.key, { signal })))
|
|
181
|
+
if (!await callStorage("storage-error", (signal) => results.exists(rendition.key, { signal }))) {
|
|
182
|
+
const degradation = await limiter.run(() => render(recipe, sourceKey, rendition.key));
|
|
183
|
+
if (degradation) notify(onDegradation, {
|
|
184
|
+
...degradation,
|
|
185
|
+
recipe,
|
|
186
|
+
target,
|
|
187
|
+
context,
|
|
188
|
+
rendition: { ...rendition }
|
|
189
|
+
});
|
|
190
|
+
}
|
|
184
191
|
await markPublished(rendition.url);
|
|
185
192
|
}
|
|
186
193
|
return rendition;
|
|
@@ -193,6 +200,12 @@ function createRenditionServer(options) {
|
|
|
193
200
|
resolve
|
|
194
201
|
};
|
|
195
202
|
}
|
|
203
|
+
/** Calls an optional callback without waiting for it, so a failing or hanging callback cannot affect the operation. */
|
|
204
|
+
function notify(callback, value) {
|
|
205
|
+
try {
|
|
206
|
+
Promise.resolve(callback?.(value)).catch(() => {});
|
|
207
|
+
} catch {}
|
|
208
|
+
}
|
|
196
209
|
/**
|
|
197
210
|
* Runs a call with an abort signal that fires after the timeout.
|
|
198
211
|
*
|
package/dist/sharp.d.mts
CHANGED
package/dist/sharp.mjs
CHANGED
|
@@ -115,12 +115,13 @@ function createSharpProcessor({ maxInputPixels = 1e8, maxAnimationPixels = 5e7,
|
|
|
115
115
|
assertRecipe,
|
|
116
116
|
async process(source, recipe) {
|
|
117
117
|
assertRecipe(recipe);
|
|
118
|
-
const data = await render(source, recipe, maxInputPixels, limits).catch((error) => {
|
|
118
|
+
const { data, degradation } = await render(source, recipe, maxInputPixels, limits).catch((error) => {
|
|
119
119
|
throw error instanceof RenditionError ? error : new RenditionError("unsupported-source", "The source cannot be processed.", { cause: error });
|
|
120
120
|
});
|
|
121
121
|
return {
|
|
122
122
|
data: new Uint8Array(data),
|
|
123
|
-
contentType: formatContentTypes[recipe.format]
|
|
123
|
+
contentType: formatContentTypes[recipe.format],
|
|
124
|
+
degradation
|
|
124
125
|
};
|
|
125
126
|
}
|
|
126
127
|
};
|
|
@@ -150,7 +151,8 @@ async function render(source, recipe, maxInputPixels, limits) {
|
|
|
150
151
|
width = raster.width;
|
|
151
152
|
height = raster.height;
|
|
152
153
|
}
|
|
153
|
-
const
|
|
154
|
+
const animation = planAnimation(recipe, metadata, limits);
|
|
155
|
+
const frames = animation.animated ? {
|
|
154
156
|
animated: true,
|
|
155
157
|
limitInputPixels: limits.maxAnimationPixels
|
|
156
158
|
} : { pages: 1 };
|
|
@@ -164,6 +166,12 @@ async function render(source, recipe, maxInputPixels, limits) {
|
|
|
164
166
|
if (resize) pipeline = pipeline.resize(resize);
|
|
165
167
|
if (recipe.format === "jpeg") pipeline = pipeline.flatten({ background: parseColor(recipe.background) });
|
|
166
168
|
pipeline = pipeline.toColourspace("srgb");
|
|
169
|
+
return {
|
|
170
|
+
data: await encode(pipeline, recipe),
|
|
171
|
+
degradation: animation.degradation
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function encode(pipeline, recipe) {
|
|
167
175
|
switch (recipe.format) {
|
|
168
176
|
case "avif": return pipeline.avif({
|
|
169
177
|
quality: recipe.quality,
|
|
@@ -178,14 +186,26 @@ async function render(source, recipe, maxInputPixels, limits) {
|
|
|
178
186
|
}
|
|
179
187
|
}
|
|
180
188
|
/**
|
|
181
|
-
*
|
|
189
|
+
* Plans whether a rendition keeps the animation of a source, judged by the metadata of its first frame.
|
|
182
190
|
*
|
|
183
191
|
* Sharp resizes and crops the frames one by one, so the planned geometry of the first frame applies to every frame.
|
|
184
192
|
*/
|
|
185
|
-
function
|
|
186
|
-
if (recipe.animation !== "preserve" || format !== "gif" && format !== "webp" || pages < 2
|
|
193
|
+
function planAnimation(recipe, { format, pages = 1, width, height, orientation = 1 }, { maxAnimationPixels, maxFrames }) {
|
|
194
|
+
if (recipe.animation !== "preserve" || format !== "gif" && format !== "webp" || pages < 2) return { animated: false };
|
|
195
|
+
if (orientation !== 1) return degrade("animation-orientation", `Sharp cannot apply the EXIF orientation ${orientation} to the frames of an animation.`);
|
|
196
|
+
if (maxFrames > 0 && pages > maxFrames) return degrade("animation-frames", `The animation of ${pages} frames exceeds the frame limit of ${maxFrames}.`);
|
|
187
197
|
const pixels = Math.max(width * height, estimateRenditionArea(recipe, width, height)) * pages;
|
|
188
|
-
|
|
198
|
+
if (maxAnimationPixels > 0 && pixels > maxAnimationPixels) return degrade("animation-pixels", `The animation of ${pixels} pixels exceeds the animation pixel limit of ${maxAnimationPixels}.`);
|
|
199
|
+
return { animated: true };
|
|
200
|
+
}
|
|
201
|
+
function degrade(reason, message) {
|
|
202
|
+
return {
|
|
203
|
+
animated: false,
|
|
204
|
+
degradation: {
|
|
205
|
+
reason,
|
|
206
|
+
message
|
|
207
|
+
}
|
|
208
|
+
};
|
|
189
209
|
}
|
|
190
210
|
/**
|
|
191
211
|
* Checks that sharp can keep an animation where a recipe asks for it.
|
|
@@ -1,15 +1,5 @@
|
|
|
1
|
-
import { i as formatExtensions
|
|
1
|
+
import { i as formatExtensions } from "./recipe-BavgOq0b.mjs";
|
|
2
2
|
import { n as isSafePath, r as isSafeScope, t as encodePath } from "./path-hNFUjCbA.mjs";
|
|
3
|
-
import { sha256 } from "@noble/hashes/sha2.js";
|
|
4
|
-
import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
|
|
5
|
-
//#region src/hash.ts
|
|
6
|
-
/** Length of a recipe hash in hex digits, which keeps 128 bits of SHA-256. */
|
|
7
|
-
const RECIPE_HASH_LENGTH = 32;
|
|
8
|
-
/** Identifies a recipe by the truncated SHA-256 of its canonical serialization. */
|
|
9
|
-
function hashRecipe(recipe) {
|
|
10
|
-
return bytesToHex(sha256(utf8ToBytes(serializeRecipe(recipe)))).slice(0, 32);
|
|
11
|
-
}
|
|
12
|
-
//#endregion
|
|
13
3
|
//#region src/url.ts
|
|
14
4
|
/** Version segment of the rendition URL format this library builds and parses. */
|
|
15
5
|
const URL_FORMAT_VERSION = "v1";
|
|
@@ -26,7 +16,8 @@ function formatRenditionPath(basePath, { scope, label, hash, format, path }) {
|
|
|
26
16
|
const extension = formatExtensions[format];
|
|
27
17
|
if (!isSafeLabel(label) || !isHash(hash) || !extension) throw new RangeError(`Invalid rendition variant "${label}.${hash}.${extension ?? format}".`);
|
|
28
18
|
if (!isSafePath(path)) throw new RangeError(`Unsafe rendition source path "${path}".`);
|
|
29
|
-
|
|
19
|
+
const scopePrefix = scope === void 0 ? "" : `${scope}/`;
|
|
20
|
+
return `${basePath}/${URL_FORMAT_VERSION}/${scopePrefix}${label}.${hash}.${extension}/${encodePath(path)}`;
|
|
30
21
|
}
|
|
31
22
|
/**
|
|
32
23
|
* Parses the path of a rendition URL, ignoring any query string.
|
|
@@ -34,7 +25,7 @@ function formatRenditionPath(basePath, { scope, label, hash, format, path }) {
|
|
|
34
25
|
* Returns `undefined` for a path outside `basePath`, another URL format version, or malformed or unsafe parts.
|
|
35
26
|
*/
|
|
36
27
|
function parseRenditionPath(basePath, url) {
|
|
37
|
-
const prefix = `${basePath}
|
|
28
|
+
const prefix = `${basePath}/${URL_FORMAT_VERSION}/`;
|
|
38
29
|
const pathname = url.split("?", 1)[0];
|
|
39
30
|
if (!pathname.startsWith(prefix)) return;
|
|
40
31
|
const parts = pathname.slice(prefix.length).split("/");
|
|
@@ -67,8 +58,13 @@ const formats = Object.keys(formatExtensions);
|
|
|
67
58
|
function isSafeLabel(label) {
|
|
68
59
|
return /^[\w@.-]{1,64}$/.test(label);
|
|
69
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Checks that a recipe hash is 1 to 64 ASCII letters, digits, `_` or `-`, which need no encoding in a URL or a storage key.
|
|
63
|
+
*
|
|
64
|
+
* The grammar does not fix the format of hashes, so a hash the catalog cannot produce is looked up as a published rendition instead of making the URL malformed.
|
|
65
|
+
*/
|
|
70
66
|
function isHash(hash) {
|
|
71
|
-
return
|
|
67
|
+
return /^[\w-]{1,64}$/.test(hash);
|
|
72
68
|
}
|
|
73
69
|
function decodeSegment(segment) {
|
|
74
70
|
try {
|
|
@@ -78,4 +74,4 @@ function decodeSegment(segment) {
|
|
|
78
74
|
}
|
|
79
75
|
}
|
|
80
76
|
//#endregion
|
|
81
|
-
export { parseRenditionPath as
|
|
77
|
+
export { parseRenditionPath as i, assertBasePath as n, formatRenditionPath as r, DEFAULT_BASE_PATH as t };
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { s as RenditionFormat } from "./recipe-BT8D60Qb.mjs";
|
|
2
2
|
//#region src/url.d.ts
|
|
3
|
-
/** Version segment of the rendition URL format this library builds and parses. */
|
|
4
|
-
declare const URL_FORMAT_VERSION = "v1";
|
|
5
3
|
/** Mount path of the rendition handler unless a catalog sets another one. */
|
|
6
4
|
declare const DEFAULT_BASE_PATH = "/_renditions";
|
|
7
5
|
/** Parts of a rendition URL. */
|
|
@@ -28,11 +26,5 @@ declare function formatRenditionPath(basePath: string, { scope, label, hash, for
|
|
|
28
26
|
* Returns `undefined` for a path outside `basePath`, another URL format version, or malformed or unsafe parts.
|
|
29
27
|
*/
|
|
30
28
|
declare function parseRenditionPath(basePath: string, url: string): RenditionAddress | undefined;
|
|
31
|
-
/**
|
|
32
|
-
* Checks that a base path is absolute, has no trailing slash and needs no URL encoding.
|
|
33
|
-
*
|
|
34
|
-
* @throws {RangeError} When the base path is invalid.
|
|
35
|
-
*/
|
|
36
|
-
declare function assertBasePath(basePath: string): void;
|
|
37
29
|
//#endregion
|
|
38
|
-
export {
|
|
30
|
+
export { parseRenditionPath as i, RenditionAddress as n, formatRenditionPath as r, DEFAULT_BASE_PATH as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "image-renditions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "On-demand image transformations that persist every result to durable storage and keep app servers stateless.",
|
|
5
5
|
"homepage": "https://github.com/IlyaSemenov/image-renditions#readme",
|
|
6
6
|
"bugs": "https://github.com/IlyaSemenov/image-renditions/issues",
|