image-renditions 0.3.0 → 0.5.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 +21 -6
- package/dist/{catalog-D11xkViN.d.mts → catalog-6wdmwTO7.d.mts} +1 -1
- package/dist/h3.d.mts +1 -1
- package/dist/h3.mjs +1 -1
- package/dist/{handler-D5toFJ55.mjs → handler-BZx2-0oV.mjs} +1 -1
- package/dist/{handler-RAQFWtrg.d.mts → handler-BhkqhlYb.d.mts} +18 -3
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +25 -9
- package/dist/nuxt/runtime.d.mts +9 -2
- package/dist/nuxt/runtime.mjs +45 -21
- package/dist/{processor-DvyPs-OZ.d.mts → processor-Dd3jqaOW.d.mts} +18 -2
- package/dist/server.d.mts +3 -3
- package/dist/server.mjs +27 -14
- package/dist/sharp.d.mts +1 -1
- package/dist/sharp.mjs +27 -7
- package/dist/{url-C6PtYIEf.mjs → url-ookAu7xz.mjs} +25 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,10 +33,11 @@ The library is modular, with pluggable parts:
|
|
|
33
33
|
|
|
34
34
|
## When not to use it
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
Use an image service such as [imgproxy](https://imgproxy.net) or [Thumbor](https://thumbor.readthedocs.io) when the URL must choose the size, such as `?w=300`.
|
|
37
|
+
Such a service takes the size and other options from the URL, and to keep clients from changing them it can require URLs signed with a secret key, which must stay out of browser code.
|
|
38
|
+
|
|
39
|
+
image-renditions renders only the presets of its catalog: a URL carries a hash of the preset options, and a request for any other hash renders nothing.
|
|
40
|
+
Its URLs need no secret, so they can be built in the browser too, but every size must be a preset in your code.
|
|
40
41
|
|
|
41
42
|
## Quick start
|
|
42
43
|
|
|
@@ -135,7 +136,7 @@ Options of a preset, or of `defaults`, which every preset overrides field by fie
|
|
|
135
136
|
- `format` — `avif` (default), `webp`, `jpeg` or `png`.
|
|
136
137
|
- `quality` — encoder quality from 1 to 100, by default 50 for `avif` and 80 for `webp` and `jpeg`; `png` is lossless.
|
|
137
138
|
- `effort` — CPU effort of the `avif` (0–9) and `webp` (0–6) encoders, 4 by default; other formats ignore it.
|
|
138
|
-
- `densities` — pixel densities to render, `[1]` by default.
|
|
139
|
+
- `densities` — pixel densities to render, which include `1`; `[1]` by default.
|
|
139
140
|
- `animation` — `first-frame` (default) renders the first frame of animated sources, and `preserve` keeps the animation of GIF and WebP sources; see [Animation](#animation).
|
|
140
141
|
- `originals` — extensions of sources shown as they are, so no option above applies to them; see [Originals](#originals).
|
|
141
142
|
|
|
@@ -167,6 +168,7 @@ renditions.url("cover", post.coverPath)
|
|
|
167
168
|
renditions.url("avatar", path, { density: 2 })
|
|
168
169
|
renditions.srcset("avatar", path)
|
|
169
170
|
renditions.attrs("avatar", path) // src, srcset and size of <RenditionImg>, for your own img element
|
|
171
|
+
renditions.attrs("avatar", path, { size: false }) // src and srcset, for a component that sizes the image itself
|
|
170
172
|
```
|
|
171
173
|
|
|
172
174
|
`url()` and `srcset()` return `undefined` for an unsafe path.
|
|
@@ -409,6 +411,18 @@ It renders the first frame of other animated sources, such as APNG and animated
|
|
|
409
411
|
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.
|
|
410
412
|
Changing the limits keeps stored renditions as they are; bump `pipelineVersion` to render them again.
|
|
411
413
|
|
|
414
|
+
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.
|
|
415
|
+
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:
|
|
416
|
+
|
|
417
|
+
```ts
|
|
418
|
+
const server = createRenditionServer({
|
|
419
|
+
// ...
|
|
420
|
+
onDegradation({ message, target }) {
|
|
421
|
+
console.warn(`The rendition of ${target.path} is degraded: ${message}`)
|
|
422
|
+
},
|
|
423
|
+
})
|
|
424
|
+
```
|
|
425
|
+
|
|
412
426
|
To process images with another library, implement the [`Processor`](#extending) interface and pass your implementation to `createRenditionServer()` as `processor`.
|
|
413
427
|
|
|
414
428
|
## h3 and other frameworks
|
|
@@ -506,7 +520,7 @@ If `publicUrl` never changes, pass a longer `redirectCacheControl` to `defineRen
|
|
|
506
520
|
|
|
507
521
|
## Concurrency and timeouts
|
|
508
522
|
|
|
509
|
-
|
|
523
|
+
The `limits` option of `createRenditionServer()` bounds rendering per process:
|
|
510
524
|
|
|
511
525
|
- `concurrency` — renditions processed at once, 2 by default.
|
|
512
526
|
- `queueSize` — renditions waiting for a slot, 100 by default.
|
|
@@ -576,6 +590,7 @@ Implement an interface from `image-renditions/server` and pass your implementati
|
|
|
576
590
|
- To use other storage, implement `SourceStore` or `ResultStore` and pass it as `source` or `results`.
|
|
577
591
|
- To process images with another library, implement `Processor` and pass it as `processor`.
|
|
578
592
|
Implement its `assertRecipe()` to reject recipes it cannot render, such as animations, when the server is created.
|
|
593
|
+
Return `degradation` from its `process()` to report an image that falls short of its recipe to `onDegradation`.
|
|
579
594
|
- To share the index of published renditions between replicas, for example in Redis, implement `ReadinessIndex` and pass it as `readiness.shared`.
|
|
580
595
|
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`.
|
|
581
596
|
- To render each rendition once across replicas, implement `Coordinator` as a shared lock and pass it as `coordinator`.
|
|
@@ -2,7 +2,7 @@ import { a as RenditionAnimation, c as RenditionOptions, r as Recipe, u as Sourc
|
|
|
2
2
|
//#region src/catalog.d.ts
|
|
3
3
|
/** Named transformation in a catalog. */
|
|
4
4
|
interface PresetDefinition extends RenditionOptions {
|
|
5
|
-
/** Pixel densities to render, as multipliers of `width` and `height`; defaults to `[1]`. */
|
|
5
|
+
/** Pixel densities to render, as multipliers of `width` and `height`, which include `1`; defaults to `[1]`. */
|
|
6
6
|
densities?: readonly number[];
|
|
7
7
|
/**
|
|
8
8
|
* Extensions of source files shown as they are instead of rendered, such as `svg`, in lowercase and without the dot.
|
package/dist/h3.d.mts
CHANGED
package/dist/h3.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { r as toDeclaredTarget } from "./scopes-C1kzqQHz.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { s as parseRenditionPath } from "./url-ookAu7xz.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
|
-
import { a as RenditionTarget, t as Catalog } from "./catalog-
|
|
2
|
+
import { a as RenditionTarget, t as Catalog } from "./catalog-6wdmwTO7.mjs";
|
|
3
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 };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
|
-
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-
|
|
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-6wdmwTO7.mjs";
|
|
3
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
5
|
/** Identifies a recipe by the truncated SHA-256 of its canonical serialization. */
|
package/dist/index.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { a as normalizeRecipe, i as formatExtensions, n as RECIPE_ENGINE_VERSION
|
|
|
2
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
|
|
5
|
+
import { a as isSafeLabel, i as formatRenditionPath, n as assertBasePath, o as joinRenditionPath, r as assertSafeTarget, s as parseRenditionPath, t as DEFAULT_BASE_PATH } from "./url-ookAu7xz.mjs";
|
|
6
6
|
//#region src/catalog.ts
|
|
7
7
|
/** Extensions of sources that get animated variants, as a path does not tell an animated source from a still one. */
|
|
8
8
|
const ANIMATED_EXTENSIONS = ["gif", "webp"];
|
|
@@ -35,6 +35,7 @@ function defineCatalog(definition) {
|
|
|
35
35
|
...omitUndefined(definition.presets[preset])
|
|
36
36
|
};
|
|
37
37
|
if (densities.length === 0 || new Set(densities).size !== densities.length) throw new RangeError(`Preset "${preset}" must declare unique densities.`);
|
|
38
|
+
if (!densities.includes(1)) throw new RangeError(`Preset "${preset}" must include density 1.`);
|
|
38
39
|
if (!originals.every((extension) => /^[\da-z]+$/.test(extension))) throw new RangeError(`Preset "${preset}" must list original extensions in lowercase and without the dot.`);
|
|
39
40
|
const sortedDensities = [...densities].sort((a, b) => a - b);
|
|
40
41
|
const createVariants = (variantOptions, animation) => Object.freeze(sortedDensities.map((density) => {
|
|
@@ -45,10 +46,12 @@ function defineCatalog(definition) {
|
|
|
45
46
|
policy: definition.sources,
|
|
46
47
|
animation
|
|
47
48
|
}));
|
|
49
|
+
const label = density === 1 ? preset : `${preset}@${density}x`;
|
|
50
|
+
if (!isSafeLabel(label)) throw new RangeError(`Invalid variant label "${label}" of preset "${preset}".`);
|
|
48
51
|
return Object.freeze({
|
|
49
52
|
preset,
|
|
50
53
|
density,
|
|
51
|
-
label
|
|
54
|
+
label,
|
|
52
55
|
recipe,
|
|
53
56
|
hash: hashRecipe(recipe)
|
|
54
57
|
});
|
|
@@ -85,15 +88,24 @@ function defineCatalog(definition) {
|
|
|
85
88
|
if (!found) throw new RangeError(`Preset "${preset}" has no density ${density}.`);
|
|
86
89
|
return found;
|
|
87
90
|
}
|
|
88
|
-
|
|
91
|
+
/** Checks a target and encodes its path, once for all the variant URLs of a call. */
|
|
92
|
+
function encodeTarget({ scope, path }) {
|
|
89
93
|
assertDeclaredScope(scopes, scope);
|
|
90
|
-
|
|
94
|
+
assertSafeTarget(scope, path);
|
|
95
|
+
return encodePath(path);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* URL of a variant for a target that `encodeTarget()` has checked and encoded.
|
|
99
|
+
*
|
|
100
|
+
* The base path and the labels are checked when the catalog is defined, and hashes are always safe.
|
|
101
|
+
*/
|
|
102
|
+
function joinVariantUrl({ label, hash, recipe }, scope, encodedPath) {
|
|
103
|
+
return joinRenditionPath(basePath, {
|
|
91
104
|
scope,
|
|
92
105
|
label,
|
|
93
106
|
hash,
|
|
94
|
-
format: recipe.format
|
|
95
|
-
|
|
96
|
-
});
|
|
107
|
+
format: recipe.format
|
|
108
|
+
}, encodedPath);
|
|
97
109
|
}
|
|
98
110
|
return Object.freeze({
|
|
99
111
|
basePath,
|
|
@@ -105,8 +117,12 @@ function defineCatalog(definition) {
|
|
|
105
117
|
isOriginal: (preset, path) => hasExtension(path, getPreset(preset).originals),
|
|
106
118
|
allVariants: Object.freeze([...variantsByHash.values()]),
|
|
107
119
|
findVariant: (hash) => variantsByHash.get(hash),
|
|
108
|
-
url: (preset, target, density) =>
|
|
109
|
-
srcset: (preset, target) =>
|
|
120
|
+
url: (preset, target, density) => joinVariantUrl(variant(preset, density, target.path), target.scope, encodeTarget(target)),
|
|
121
|
+
srcset: (preset, target) => {
|
|
122
|
+
const found = variants(preset, target.path);
|
|
123
|
+
const encodedPath = encodeTarget(target);
|
|
124
|
+
return found.map((entry) => `${joinVariantUrl(entry, target.scope, encodedPath)} ${entry.density}x`).join(", ");
|
|
125
|
+
}
|
|
110
126
|
});
|
|
111
127
|
}
|
|
112
128
|
/** Whether the extension of a path is in a list of lowercase extensions. */
|
package/dist/nuxt/runtime.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as RenditionTarget, t as Catalog } from "../catalog-
|
|
1
|
+
import { a as RenditionTarget, t as Catalog } from "../catalog-6wdmwTO7.mjs";
|
|
2
2
|
import { PropType } from "vue";
|
|
3
3
|
//#region src/nuxt/runtime.d.ts
|
|
4
4
|
/** Attributes of an `img` element showing a rendition. */
|
|
@@ -27,6 +27,13 @@ export interface RenditionUrlOptions<Scope extends string = string> {
|
|
|
27
27
|
/** Density of the variant; defaults to `1`. */
|
|
28
28
|
density?: number;
|
|
29
29
|
}
|
|
30
|
+
/** Options of the `img` attributes of a rendition. */
|
|
31
|
+
export interface RenditionAttrsOptions<Scope extends string = string> {
|
|
32
|
+
/** Scope of the path; defaults to the module's `defaultScope`. */
|
|
33
|
+
scope?: Scope;
|
|
34
|
+
/** Whether to set `width` and `height`; `false` leaves the size to a component that sizes the image itself. */
|
|
35
|
+
size?: boolean;
|
|
36
|
+
}
|
|
30
37
|
/** Options of {@link createRenditionHelpers}. */
|
|
31
38
|
export interface RenditionHelpersOptions<Scope extends string = string> {
|
|
32
39
|
/** Scope of calls that set none. */
|
|
@@ -51,7 +58,7 @@ export declare function createRenditionHelpers<Name extends string, Scope extend
|
|
|
51
58
|
useRenditions: () => {
|
|
52
59
|
url: (preset: Name, path: string, { scope, density }?: RenditionUrlOptions<Scope>) => string | undefined;
|
|
53
60
|
srcset: (preset: Name, path: string, { scope }?: Pick<RenditionUrlOptions<Scope>, "scope">) => string | undefined;
|
|
54
|
-
attrs: (preset: Name, path: string, { scope }?:
|
|
61
|
+
attrs: (preset: Name, path: string, { scope, size }?: RenditionAttrsOptions<Scope>) => RenditionImgAttrs;
|
|
55
62
|
};
|
|
56
63
|
RenditionImg: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
|
|
57
64
|
preset: {
|
package/dist/nuxt/runtime.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { n as isSafePath, r as isSafeScope } from "../path-hNFUjCbA.mjs";
|
|
1
|
+
import { n as isSafePath, r as isSafeScope, t as encodePath } from "../path-hNFUjCbA.mjs";
|
|
2
2
|
import { n as isDeclaredScope, r as toDeclaredTarget, t as assertDeclaredScope } from "../scopes-C1kzqQHz.mjs";
|
|
3
|
+
import { o as joinRenditionPath } from "../url-ookAu7xz.mjs";
|
|
3
4
|
import { t as findRenditionAttr } from "../img-attrs-BoCAHpl5.mjs";
|
|
4
5
|
import { useRuntimeConfig } from "nuxt/app";
|
|
5
6
|
import { defineComponent, h } from "vue";
|
|
@@ -24,20 +25,37 @@ function createRenditionHelpers(catalog, { defaultScope, useOriginalUrl, imgAttr
|
|
|
24
25
|
/**
|
|
25
26
|
* Target of a path in the scope of a call, or `undefined` for an unsafe path.
|
|
26
27
|
*
|
|
27
|
-
* The scope
|
|
28
|
+
* The path and the scope are checked here once, since the URLs below are joined from them or built by `useOriginalUrl` rather than by `catalog.url()`, which would check them.
|
|
28
29
|
*/
|
|
29
30
|
function resolveTarget(path, scope) {
|
|
30
31
|
if (!isSafePath(path)) return;
|
|
31
32
|
const resolved = scope ?? defaultScope;
|
|
32
33
|
if (resolved !== void 0 && !isSafeScope(resolved)) throw new RangeError(`Invalid rendition scope "${resolved}".`);
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
const target = toDeclaredTarget(catalog.scopes, resolved, path);
|
|
35
|
+
if (!target) assertDeclaredScope(catalog.scopes, resolved);
|
|
36
|
+
return target;
|
|
35
37
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
/**
|
|
39
|
+
* URL of a variant below the application base URL, for a target with an encoded path.
|
|
40
|
+
*
|
|
41
|
+
* `resolveTarget()` has checked the target and the catalog its variants, so the URL is joined without checks.
|
|
42
|
+
*/
|
|
43
|
+
function joinVariantUrl({ label, hash, recipe }, scope, encodedPath) {
|
|
44
|
+
return base + joinRenditionPath(catalog.basePath, {
|
|
45
|
+
scope,
|
|
46
|
+
label,
|
|
47
|
+
hash,
|
|
48
|
+
format: recipe.format
|
|
49
|
+
}, encodedPath);
|
|
50
|
+
}
|
|
51
|
+
/** URLs of variants of a target, which encode its path once for all of them. */
|
|
52
|
+
function formatVariantUrls(variants, { scope, path }) {
|
|
53
|
+
const encodedPath = encodePath(path);
|
|
54
|
+
return variants.map((variant) => joinVariantUrl(variant, scope, encodedPath));
|
|
38
55
|
}
|
|
39
|
-
|
|
40
|
-
|
|
56
|
+
/** `srcset` value of variants and their URLs. */
|
|
57
|
+
function formatSrcset(variants, urls) {
|
|
58
|
+
return variants.map(({ density }, index) => `${urls[index]} ${density}x`).join(", ");
|
|
41
59
|
}
|
|
42
60
|
/**
|
|
43
61
|
* URL of a rendition of a logical path, or of the source itself at every declared density when the preset lists its extension in `originals`.
|
|
@@ -49,11 +67,8 @@ function createRenditionHelpers(catalog, { defaultScope, useOriginalUrl, imgAttr
|
|
|
49
67
|
function url(preset, path, { scope, density } = {}) {
|
|
50
68
|
const target = resolveTarget(path, scope);
|
|
51
69
|
if (!target) return;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return originalUrl(target);
|
|
55
|
-
}
|
|
56
|
-
return formatUrl(preset, target, density);
|
|
70
|
+
const variant = catalog.variant(preset, density, path);
|
|
71
|
+
return catalog.isOriginal(preset, path) ? originalUrl(target) : joinVariantUrl(variant, target.scope, encodePath(path));
|
|
57
72
|
}
|
|
58
73
|
/**
|
|
59
74
|
* `srcset` attribute value listing every density of a preset.
|
|
@@ -64,28 +79,37 @@ function createRenditionHelpers(catalog, { defaultScope, useOriginalUrl, imgAttr
|
|
|
64
79
|
*/
|
|
65
80
|
function srcset(preset, path, { scope } = {}) {
|
|
66
81
|
const target = resolveTarget(path, scope);
|
|
67
|
-
|
|
82
|
+
if (!target || catalog.isOriginal(preset, path)) return;
|
|
83
|
+
const variants = catalog.variants(preset, path);
|
|
84
|
+
return formatSrcset(variants, formatVariantUrls(variants, target));
|
|
68
85
|
}
|
|
69
86
|
/**
|
|
70
87
|
* Attributes of an `img` element showing every density of a preset.
|
|
71
88
|
*
|
|
72
|
-
* `width` and `height` are set only when the output always has the size of the preset box, so they never distort the image.
|
|
89
|
+
* `width` and `height` are set only when the output always has the size of the preset box, so they never distort the image, and `size: false` leaves them out.
|
|
73
90
|
* A source that is already a URL, such as a `blob:`, `data:` or `https:` one, is returned unchanged as `src`.
|
|
74
91
|
* A source whose extension the preset lists in `originals` gets only the `src` of the source itself.
|
|
75
92
|
* An unsafe path, such as one with a `..` segment, yields no `src`, so stored data never breaks rendering.
|
|
93
|
+
*
|
|
94
|
+
* @throws {RangeError} When the preset is unknown, or the scope is invalid or not one the catalog declares.
|
|
76
95
|
*/
|
|
77
|
-
function attrs(preset, path, { scope } = {}) {
|
|
96
|
+
function attrs(preset, path, { scope, size = true } = {}) {
|
|
78
97
|
if (/^(?:[a-z][\d+.a-z-]*:|\/\/)/i.test(path)) return { src: path };
|
|
79
98
|
const target = resolveTarget(path, scope);
|
|
80
99
|
if (!target) return {};
|
|
81
100
|
if (catalog.isOriginal(preset, path)) return { src: originalUrl(target) };
|
|
82
|
-
const
|
|
101
|
+
const variants = catalog.variants(preset, path);
|
|
102
|
+
const main = variants.findIndex(({ density }) => density === 1);
|
|
103
|
+
const { recipe } = variants[main];
|
|
104
|
+
const urls = formatVariantUrls(variants, target);
|
|
83
105
|
const boxed = recipe.fit === "contain" || (recipe.fit === "cover" || recipe.fit === "fill") && recipe.enlarge;
|
|
84
106
|
return {
|
|
85
|
-
src:
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
107
|
+
src: urls[main],
|
|
108
|
+
...variants.length > 1 ? { srcset: formatSrcset(variants, urls) } : void 0,
|
|
109
|
+
...size && boxed ? {
|
|
110
|
+
width: recipe.width,
|
|
111
|
+
height: recipe.height
|
|
112
|
+
} : void 0
|
|
89
113
|
};
|
|
90
114
|
}
|
|
91
115
|
return {
|
|
@@ -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-BhkqhlYb.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
|
@@ -3,7 +3,7 @@ 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-BZx2-0oV.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.
|
|
@@ -12,12 +12,33 @@ const DEFAULT_BASE_PATH = "/_renditions";
|
|
|
12
12
|
*/
|
|
13
13
|
function formatRenditionPath(basePath, { scope, label, hash, format, path }) {
|
|
14
14
|
assertBasePath(basePath);
|
|
15
|
-
|
|
15
|
+
assertSafeTarget(scope, path);
|
|
16
16
|
const extension = formatExtensions[format];
|
|
17
17
|
if (!isSafeLabel(label) || !isHash(hash) || !extension) throw new RangeError(`Invalid rendition variant "${label}.${hash}.${extension ?? format}".`);
|
|
18
|
+
return joinRenditionPath(basePath, {
|
|
19
|
+
scope,
|
|
20
|
+
label,
|
|
21
|
+
hash,
|
|
22
|
+
format
|
|
23
|
+
}, encodePath(path));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Checks that a scope and a source path are safe to put into a rendition URL.
|
|
27
|
+
*
|
|
28
|
+
* @throws {RangeError} When the scope or the path is unsafe.
|
|
29
|
+
*/
|
|
30
|
+
function assertSafeTarget(scope, path) {
|
|
31
|
+
if (scope !== void 0 && !isSafeScope(scope)) throw new RangeError(`Invalid rendition scope "${scope}".`);
|
|
18
32
|
if (!isSafePath(path)) throw new RangeError(`Unsafe rendition source path "${path}".`);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Joins the parts of a rendition URL path without checking them, with the source path already encoded.
|
|
36
|
+
*
|
|
37
|
+
* It serves parts checked once for several URLs, such as the variants of a catalog for one target.
|
|
38
|
+
*/
|
|
39
|
+
function joinRenditionPath(basePath, { scope, label, hash, format }, encodedPath) {
|
|
19
40
|
const scopePrefix = scope === void 0 ? "" : `${scope}/`;
|
|
20
|
-
return `${basePath}/${URL_FORMAT_VERSION}/${scopePrefix}${label}.${hash}.${
|
|
41
|
+
return `${basePath}/${URL_FORMAT_VERSION}/${scopePrefix}${label}.${hash}.${formatExtensions[format]}/${encodedPath}`;
|
|
21
42
|
}
|
|
22
43
|
/**
|
|
23
44
|
* Parses the path of a rendition URL, ignoring any query string.
|
|
@@ -55,6 +76,7 @@ function assertBasePath(basePath) {
|
|
|
55
76
|
if (!/^(?:\/[\w.~-]+)+$/.test(basePath)) throw new RangeError(`Invalid rendition base path "${basePath}".`);
|
|
56
77
|
}
|
|
57
78
|
const formats = Object.keys(formatExtensions);
|
|
79
|
+
/** Checks that a variant label is 1 to 64 characters that need no encoding in a URL. */
|
|
58
80
|
function isSafeLabel(label) {
|
|
59
81
|
return /^[\w@.-]{1,64}$/.test(label);
|
|
60
82
|
}
|
|
@@ -74,4 +96,4 @@ function decodeSegment(segment) {
|
|
|
74
96
|
}
|
|
75
97
|
}
|
|
76
98
|
//#endregion
|
|
77
|
-
export {
|
|
99
|
+
export { isSafeLabel as a, formatRenditionPath as i, assertBasePath as n, joinRenditionPath as o, assertSafeTarget as r, parseRenditionPath as s, 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.5.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",
|