image-renditions 0.1.0 → 0.3.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 +193 -42
- package/dist/catalog-D11xkViN.d.mts +130 -0
- package/dist/fs.d.mts +2 -2
- package/dist/fs.mjs +1 -1
- package/dist/h3.d.mts +3 -3
- package/dist/h3.mjs +1 -1
- package/dist/{handler-BHMwNZnI.mjs → handler-D5toFJ55.mjs} +12 -10
- package/dist/{handler-BFV_vIr4.d.mts → handler-RAQFWtrg.d.mts} +41 -21
- package/dist/hash-CHipV1N6.mjs +16 -0
- package/dist/img-attrs-BoCAHpl5.mjs +14 -0
- package/dist/index.d.mts +4 -6
- package/dist/index.mjs +63 -19
- package/dist/nuxt/runtime.d.mts +37 -11
- package/dist/nuxt/runtime.mjs +67 -16
- package/dist/nuxt.d.mts +11 -1
- package/dist/nuxt.mjs +18 -3
- package/dist/processor-DvyPs-OZ.d.mts +22 -0
- package/dist/{recipe-Je9DxqO1.d.mts → recipe-BT8D60Qb.d.mts} +11 -5
- package/dist/{recipe-DmOhQJWE.mjs → recipe-BavgOq0b.mjs} +7 -6
- package/dist/s3.d.mts +8 -34
- package/dist/s3.mjs +8 -7
- package/dist/scopes-C1kzqQHz.mjs +22 -0
- package/dist/server.d.mts +3 -3
- package/dist/server.mjs +37 -14
- package/dist/sharp.d.mts +16 -4
- package/dist/sharp.mjs +86 -24
- package/dist/{stores-DFDRMZcC.d.mts → stores-P-TUmax4.d.mts} +1 -1
- package/dist/{url-BIvAw2BR.mjs → url-C6PtYIEf.mjs} +11 -15
- package/dist/{url-BByyAbmB.d.mts → url-DoJdUx9J.d.mts} +2 -10
- package/package.json +5 -1
- package/dist/catalog-CSnosQ1L.d.mts +0 -64
- package/dist/processor-_xuwB7Aa.d.mts +0 -14
package/dist/nuxt.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { r as isSafeScope } from "./path-hNFUjCbA.mjs";
|
|
2
|
+
import { n as isDeclaredScope } from "./scopes-C1kzqQHz.mjs";
|
|
3
|
+
import { t as findRenditionAttr } from "./img-attrs-BoCAHpl5.mjs";
|
|
2
4
|
import { createJiti } from "jiti";
|
|
3
5
|
import { addComponent, addImports, addServerHandler, addTemplate, defineNuxtModule, resolvePath } from "nuxt/kit";
|
|
4
6
|
//#region src/nuxt/module.ts
|
|
@@ -10,11 +12,16 @@ const module = defineNuxtModule({
|
|
|
10
12
|
name: "image-renditions",
|
|
11
13
|
configKey: "renditions"
|
|
12
14
|
},
|
|
13
|
-
async setup({ catalog: catalogModule, handler, defaultScope }, nuxt) {
|
|
15
|
+
async setup({ catalog: catalogModule, handler, defaultScope, originalUrl, imgAttrs }, nuxt) {
|
|
14
16
|
if (!catalogModule) throw new Error("[image-renditions] Set `renditions.catalog` to the module exporting the catalog.");
|
|
15
17
|
if (defaultScope !== void 0 && !isSafeScope(defaultScope)) throw new Error(`[image-renditions] Invalid default scope "${defaultScope}".`);
|
|
18
|
+
const renditionAttr = findRenditionAttr(imgAttrs);
|
|
19
|
+
if (renditionAttr) throw new Error(`[image-renditions] \`imgAttrs\` cannot set \`${renditionAttr}\`, which the rendition sets.`);
|
|
16
20
|
const catalogPath = await resolvePath(catalogModule);
|
|
17
21
|
const catalog = await createJiti(nuxt.options.rootDir, { alias: nuxt.options.alias }).import(catalogPath, { default: true });
|
|
22
|
+
if (defaultScope !== void 0 && !isDeclaredScope(catalog.scopes, defaultScope)) throw new Error(`[image-renditions] The default scope "${defaultScope}" is not one of the catalog scopes.`);
|
|
23
|
+
if (!originalUrl && catalog.presets.some((preset) => catalog.originals(preset).length > 0)) throw new Error("[image-renditions] Set `renditions.originalUrl` to the module building URLs of originals, as a preset of the catalog lists `originals`.");
|
|
24
|
+
const originalUrlPath = originalUrl && await resolvePath(originalUrl);
|
|
18
25
|
if (handler) addServerHandler({
|
|
19
26
|
route: `${catalog.basePath}/**`,
|
|
20
27
|
handler: await resolvePath(handler)
|
|
@@ -23,10 +30,15 @@ const module = defineNuxtModule({
|
|
|
23
30
|
filename: "image-renditions.ts",
|
|
24
31
|
write: true,
|
|
25
32
|
getContents: () => [
|
|
26
|
-
`import catalog from ${
|
|
33
|
+
`import catalog from ${formatImportSpecifier(catalogPath)}`,
|
|
34
|
+
...originalUrlPath ? [`import useOriginalUrl from ${formatImportSpecifier(originalUrlPath)}`] : [],
|
|
27
35
|
`import { createRenditionHelpers } from "image-renditions/nuxt/runtime"`,
|
|
28
36
|
"",
|
|
29
|
-
`export const { useRenditions, RenditionImg } = createRenditionHelpers(catalog,
|
|
37
|
+
`export const { useRenditions, RenditionImg } = createRenditionHelpers(catalog, {`,
|
|
38
|
+
` defaultScope: ${JSON.stringify(defaultScope)},`,
|
|
39
|
+
` useOriginalUrl: ${originalUrlPath ? "useOriginalUrl" : "undefined"},`,
|
|
40
|
+
` imgAttrs: ${JSON.stringify(imgAttrs)},`,
|
|
41
|
+
`})`,
|
|
30
42
|
""
|
|
31
43
|
].join("\n")
|
|
32
44
|
});
|
|
@@ -41,5 +53,8 @@ const module = defineNuxtModule({
|
|
|
41
53
|
});
|
|
42
54
|
}
|
|
43
55
|
});
|
|
56
|
+
function formatImportSpecifier(path) {
|
|
57
|
+
return JSON.stringify(path.replace(/\.[cm]?[jt]s$/, ""));
|
|
58
|
+
}
|
|
44
59
|
//#endregion
|
|
45
60
|
export { module as default };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { r as Recipe } from "./recipe-BT8D60Qb.mjs";
|
|
2
|
+
import { t as EncodedImage } from "./stores-P-TUmax4.mjs";
|
|
3
|
+
//#region src/server/processor.d.ts
|
|
4
|
+
/** Renders a source image according to a recipe. */
|
|
5
|
+
interface Processor {
|
|
6
|
+
/**
|
|
7
|
+
* Renders the source.
|
|
8
|
+
*
|
|
9
|
+
* 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
|
+
*/
|
|
11
|
+
process(source: Uint8Array, recipe: Recipe): Promise<EncodedImage>;
|
|
12
|
+
/**
|
|
13
|
+
* Checks that the processor renders a recipe as it describes.
|
|
14
|
+
*
|
|
15
|
+
* `createRenditionServer()` checks every variant of its catalog with it, and `ensure()` every recipe it gets.
|
|
16
|
+
*
|
|
17
|
+
* @throws {RangeError} When the processor cannot render the recipe, such as an animation in a format it cannot encode.
|
|
18
|
+
*/
|
|
19
|
+
assertRecipe?(recipe: Recipe): void;
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
export { Processor as t };
|
|
@@ -13,12 +13,16 @@ type RenditionFit = "cover" | "contain" | "fill" | "inside" | "outside";
|
|
|
13
13
|
* `entropy` and `attention` choose the crop by content and apply to `cover` only.
|
|
14
14
|
*/
|
|
15
15
|
type RenditionPosition = "center" | "top" | "bottom" | "left" | "right" | "entropy" | "attention";
|
|
16
|
+
/**
|
|
17
|
+
* How a rendition shows an animated source.
|
|
18
|
+
*
|
|
19
|
+
* `first-frame` renders its first frame as a still image, and `preserve` keeps the animation.
|
|
20
|
+
*/
|
|
21
|
+
type RenditionAnimation = "first-frame" | "preserve";
|
|
16
22
|
/** How sources of a special kind are turned into a rendition. */
|
|
17
23
|
interface SourcePolicy {
|
|
18
24
|
/** `rasterize` renders vector sources at the resolution the output needs. */
|
|
19
25
|
svg: "rasterize";
|
|
20
|
-
/** `first-frame` renders animated sources as a still image of their first frame. */
|
|
21
|
-
animation: "first-frame";
|
|
22
26
|
/** `auto` applies the EXIF orientation before resizing; metadata never reaches the output. */
|
|
23
27
|
orientation: "auto";
|
|
24
28
|
}
|
|
@@ -66,7 +70,7 @@ interface Recipe {
|
|
|
66
70
|
readonly quality?: number;
|
|
67
71
|
readonly effort?: number;
|
|
68
72
|
readonly svg: SourcePolicy["svg"];
|
|
69
|
-
readonly animation:
|
|
73
|
+
readonly animation: RenditionAnimation;
|
|
70
74
|
readonly orientation: SourcePolicy["orientation"];
|
|
71
75
|
}
|
|
72
76
|
/** File extension of each output format, used in rendition URLs and storage keys. */
|
|
@@ -85,14 +89,16 @@ interface RecipeInput {
|
|
|
85
89
|
/** Application-owned pipeline version. */
|
|
86
90
|
pipeline: number;
|
|
87
91
|
policy?: Partial<SourcePolicy>;
|
|
92
|
+
/** Defaults to `first-frame`. */
|
|
93
|
+
animation?: RenditionAnimation;
|
|
88
94
|
}
|
|
89
95
|
/**
|
|
90
96
|
* Builds the canonical recipe for transformation options at a density.
|
|
91
97
|
*
|
|
92
98
|
* @throws {RangeError} When an option is out of range or inconsistent.
|
|
93
99
|
*/
|
|
94
|
-
declare function normalizeRecipe({ options, density, pipeline, policy }: RecipeInput): Recipe;
|
|
100
|
+
declare function normalizeRecipe({ options, density, pipeline, policy, animation }: RecipeInput): Recipe;
|
|
95
101
|
/** Serializes a recipe deterministically: sorted keys, no undefined fields, no whitespace. */
|
|
96
102
|
declare function serializeRecipe(recipe: Recipe): string;
|
|
97
103
|
//#endregion
|
|
98
|
-
export {
|
|
104
|
+
export { RenditionAnimation as a, RenditionOptions as c, formatContentTypes as d, formatExtensions as f, RecipeInput as i, RenditionPosition as l, serializeRecipe as m, RECIPE_ENGINE_VERSION as n, RenditionFit as o, normalizeRecipe as p, Recipe as r, RenditionFormat as s, MAX_DIMENSION as t, SourcePolicy as u };
|
|
@@ -25,9 +25,9 @@ const RECIPE_ENGINE_VERSION = 1;
|
|
|
25
25
|
const MAX_DIMENSION = 16383;
|
|
26
26
|
const defaultSourcePolicy = {
|
|
27
27
|
svg: "rasterize",
|
|
28
|
-
animation: "first-frame",
|
|
29
28
|
orientation: "auto"
|
|
30
29
|
};
|
|
30
|
+
const animations = ["first-frame", "preserve"];
|
|
31
31
|
const formats = [
|
|
32
32
|
"avif",
|
|
33
33
|
"webp",
|
|
@@ -74,11 +74,12 @@ const effortRange = {
|
|
|
74
74
|
*
|
|
75
75
|
* @throws {RangeError} When an option is out of range or inconsistent.
|
|
76
76
|
*/
|
|
77
|
-
function normalizeRecipe({ options, density = 1, pipeline, policy }) {
|
|
77
|
+
function normalizeRecipe({ options, density = 1, pipeline, policy, animation = "first-frame" }) {
|
|
78
78
|
assertInteger("pipeline", pipeline, 0, Number.MAX_SAFE_INTEGER);
|
|
79
79
|
if (!(Number.isFinite(density) && density > 0)) throw new RangeError(`Invalid density ${density}.`);
|
|
80
80
|
const format = options.format ?? "avif";
|
|
81
81
|
assertOneOf("format", format, formats);
|
|
82
|
+
assertOneOf("animation", animation, animations);
|
|
82
83
|
if (options.fit !== void 0) assertOneOf("fit", options.fit, fits);
|
|
83
84
|
if (options.position !== void 0) assertOneOf("position", options.position, positions);
|
|
84
85
|
const color = options.background === void 0 ? void 0 : normalizeColor(options.background);
|
|
@@ -105,16 +106,16 @@ function normalizeRecipe({ options, density = 1, pipeline, policy }) {
|
|
|
105
106
|
format,
|
|
106
107
|
quality: format === "png" ? void 0 : options.quality ?? defaultQuality[format],
|
|
107
108
|
effort: range ? options.effort ?? range.default : void 0,
|
|
108
|
-
svg:
|
|
109
|
-
animation
|
|
110
|
-
orientation:
|
|
109
|
+
svg: resolvePolicyValue("svg", policy, ["rasterize"]),
|
|
110
|
+
animation,
|
|
111
|
+
orientation: resolvePolicyValue("orientation", policy, ["auto"])
|
|
111
112
|
});
|
|
112
113
|
}
|
|
113
114
|
/** Serializes a recipe deterministically: sorted keys, no undefined fields, no whitespace. */
|
|
114
115
|
function serializeRecipe(recipe) {
|
|
115
116
|
return JSON.stringify(Object.fromEntries(Object.entries(recipe).filter(([, value]) => value !== void 0).sort(([a], [b]) => a < b ? -1 : 1)));
|
|
116
117
|
}
|
|
117
|
-
function
|
|
118
|
+
function resolvePolicyValue(name, policy, allowed) {
|
|
118
119
|
const value = policy?.[name] ?? defaultSourcePolicy[name];
|
|
119
120
|
assertOneOf(name, value, allowed);
|
|
120
121
|
return value;
|
package/dist/s3.d.mts
CHANGED
|
@@ -1,43 +1,18 @@
|
|
|
1
|
-
import { n as ResultStore, r as SourceStore } from "./stores-
|
|
1
|
+
import { n as ResultStore, r as SourceStore } from "./stores-P-TUmax4.mjs";
|
|
2
|
+
import { S3Client } from "@aws-sdk/client-s3";
|
|
2
3
|
//#region src/stores/s3.d.ts
|
|
3
|
-
/** Per-call options of the S3 client methods. */
|
|
4
|
-
interface S3CallOptions {
|
|
5
|
-
abortSignal?: AbortSignal;
|
|
6
|
-
}
|
|
7
|
-
/** Location of an S3 object. */
|
|
8
|
-
interface S3ObjectInput {
|
|
9
|
-
Bucket: string;
|
|
10
|
-
Key: string;
|
|
11
|
-
}
|
|
12
|
-
/**
|
|
13
|
-
* Subset of an S3 client used by the S3 stores.
|
|
14
|
-
*
|
|
15
|
-
* The aggregated `S3` client of AWS SDK v3 (`@aws-sdk/client-s3`) satisfies it as is; other clients need a thin wrapper.
|
|
16
|
-
*/
|
|
17
|
-
export interface S3ClientLike {
|
|
18
|
-
getObject(input: S3ObjectInput, options?: S3CallOptions): Promise<{
|
|
19
|
-
Body?: {
|
|
20
|
-
transformToWebStream(): ReadableStream<Uint8Array>;
|
|
21
|
-
};
|
|
22
|
-
ContentLength?: number;
|
|
23
|
-
}>;
|
|
24
|
-
headObject(input: S3ObjectInput, options?: S3CallOptions): Promise<unknown>;
|
|
25
|
-
putObject(input: S3ObjectInput & {
|
|
26
|
-
Body: Uint8Array;
|
|
27
|
-
ContentType: string;
|
|
28
|
-
CacheControl: string;
|
|
29
|
-
}, options?: S3CallOptions): Promise<unknown>;
|
|
30
|
-
}
|
|
31
4
|
/** Options of {@link createS3Source}. */
|
|
32
5
|
export interface S3SourceOptions {
|
|
33
|
-
|
|
6
|
+
/** Client of AWS SDK v3, such as `S3Client` or the aggregated `S3`. */
|
|
7
|
+
client: Pick<S3Client, "send">;
|
|
34
8
|
bucket: string;
|
|
35
|
-
/** Largest source object, in bytes; larger sources
|
|
9
|
+
/** Largest source object, in bytes; larger sources fail as too large. */
|
|
36
10
|
maxBytes?: number;
|
|
37
11
|
}
|
|
38
12
|
/** Options of {@link createS3Results}. */
|
|
39
13
|
export interface S3ResultsOptions {
|
|
40
|
-
|
|
14
|
+
/** Client of AWS SDK v3, such as `S3Client` or the aggregated `S3`. */
|
|
15
|
+
client: Pick<S3Client, "send">;
|
|
41
16
|
bucket: string;
|
|
42
17
|
/** Public URL of the bucket root, such as `https://storage.example.com/bucket`; objects must be publicly readable. */
|
|
43
18
|
publicUrl: string;
|
|
@@ -48,5 +23,4 @@ export interface S3ResultsOptions {
|
|
|
48
23
|
export declare function createS3Source({ client, bucket, maxBytes }: S3SourceOptions): SourceStore;
|
|
49
24
|
/** Creates a result store writing objects to an S3 bucket that serves them publicly. */
|
|
50
25
|
export declare function createS3Results({ client, bucket, publicUrl, cacheControl }: S3ResultsOptions): ResultStore;
|
|
51
|
-
//#endregion
|
|
52
|
-
export {};
|
|
26
|
+
//#endregion
|
package/dist/s3.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { t as encodePath } from "./path-hNFUjCbA.mjs";
|
|
2
2
|
import { t as RenditionError } from "./error-DViVpVmH.mjs";
|
|
3
|
+
import { GetObjectCommand, HeadObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
|
|
3
4
|
//#region src/stores/s3.ts
|
|
4
5
|
/** Longest S3 object key, in UTF-8 bytes. */
|
|
5
6
|
const MAX_KEY_BYTES = 1024;
|
|
@@ -9,12 +10,12 @@ function createS3Source({ client, bucket, maxBytes = 52428800 }) {
|
|
|
9
10
|
return { async read(key, { signal }) {
|
|
10
11
|
if (!fitsKeyLimit(key)) return;
|
|
11
12
|
const controller = new AbortController();
|
|
12
|
-
const output = await client.
|
|
13
|
+
const output = await client.send(new GetObjectCommand({
|
|
13
14
|
Bucket: bucket,
|
|
14
15
|
Key: key
|
|
15
|
-
}, { abortSignal: AbortSignal.any([signal, controller.signal]) }).catch(ignoreNotFound);
|
|
16
|
+
}), { abortSignal: AbortSignal.any([signal, controller.signal]) }).catch(ignoreNotFound);
|
|
16
17
|
if (!output?.Body) return;
|
|
17
|
-
const tooLarge = new RenditionError("
|
|
18
|
+
const tooLarge = new RenditionError("source-too-large", `The source ${key} exceeds ${maxBytes} bytes.`);
|
|
18
19
|
if ((output.ContentLength ?? 0) > maxBytes) {
|
|
19
20
|
controller.abort();
|
|
20
21
|
throw tooLarge;
|
|
@@ -45,20 +46,20 @@ function createS3Results({ client, bucket, publicUrl, cacheControl = "public, ma
|
|
|
45
46
|
return {
|
|
46
47
|
async exists(key, { signal }) {
|
|
47
48
|
assertKeyLimit(key);
|
|
48
|
-
return await client.
|
|
49
|
+
return await client.send(new HeadObjectCommand({
|
|
49
50
|
Bucket: bucket,
|
|
50
51
|
Key: key
|
|
51
|
-
}, { abortSignal: signal }).catch(ignoreNotFound) !== void 0;
|
|
52
|
+
}), { abortSignal: signal }).catch(ignoreNotFound) !== void 0;
|
|
52
53
|
},
|
|
53
54
|
async write(key, { data, contentType }, { signal }) {
|
|
54
55
|
assertKeyLimit(key);
|
|
55
|
-
await client.
|
|
56
|
+
await client.send(new PutObjectCommand({
|
|
56
57
|
Bucket: bucket,
|
|
57
58
|
Key: key,
|
|
58
59
|
Body: data,
|
|
59
60
|
ContentType: contentType,
|
|
60
61
|
CacheControl: cacheControl
|
|
61
|
-
}, { abortSignal: signal });
|
|
62
|
+
}), { abortSignal: signal });
|
|
62
63
|
},
|
|
63
64
|
url: (key) => `${publicUrl.replace(/\/$/, "")}/${encodePath(key)}`
|
|
64
65
|
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/scopes.ts
|
|
2
|
+
/** Checks that a target has a scope the catalog declares; a catalog without scopes accepts any. */
|
|
3
|
+
function isDeclaredScope(scopes, scope) {
|
|
4
|
+
return !scopes || scope !== void 0 && scopes.includes(scope);
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Throws a `RangeError` unless a target has a scope the catalog declares.
|
|
8
|
+
*
|
|
9
|
+
* @throws {RangeError} When the scope is missing or not declared.
|
|
10
|
+
*/
|
|
11
|
+
function assertDeclaredScope(scopes, scope) {
|
|
12
|
+
if (!isDeclaredScope(scopes, scope)) throw new RangeError(scope === void 0 ? "Missing rendition scope." : `Undeclared rendition scope "${scope}".`);
|
|
13
|
+
}
|
|
14
|
+
/** Target of a scope and a path, typed by the scopes of the catalog, or `undefined` when the catalog does not declare the scope. */
|
|
15
|
+
function toDeclaredTarget(scopes, scope, path) {
|
|
16
|
+
return isDeclaredScope(scopes, scope) ? {
|
|
17
|
+
scope,
|
|
18
|
+
path
|
|
19
|
+
} : void 0;
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
export { isDeclaredScope as n, toDeclaredTarget as r, assertDeclaredScope as t };
|
package/dist/server.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { _ as RenditionFailureReason, a as createRenditionHandler, c as RenditionServerOptions, d as MemoryReadinessIndexOptions, f as ReadinessIndex, g as RenditionError, h as createLocalCoordinator, i as RenditionRequest, l as SourceResolution, m as Coordinator, n as RenditionHandler, o as Rendition, p as createMemoryReadinessIndex, r as RenditionHandlerOptions, s as RenditionServer, t as RenditionFailure, u as createRenditionServer } from "./handler-
|
|
2
|
-
import { i as StoreCallOptions, n as ResultStore, r as SourceStore, t as EncodedImage } from "./stores-
|
|
3
|
-
import { t as Processor } from "./processor-
|
|
1
|
+
import { _ as RenditionFailureReason, a as createRenditionHandler, c as RenditionServerOptions, d as MemoryReadinessIndexOptions, f as ReadinessIndex, g as RenditionError, h as createLocalCoordinator, i as RenditionRequest, l as SourceResolution, m as Coordinator, n as RenditionHandler, o as Rendition, p as createMemoryReadinessIndex, r as RenditionHandlerOptions, s as RenditionServer, t as RenditionFailure, u as createRenditionServer } from "./handler-RAQFWtrg.mjs";
|
|
2
|
+
import { i as StoreCallOptions, n as ResultStore, r as SourceStore, t as EncodedImage } from "./stores-P-TUmax4.mjs";
|
|
3
|
+
import { t as Processor } from "./processor-DvyPs-OZ.mjs";
|
|
4
4
|
export { Coordinator, EncodedImage, MemoryReadinessIndexOptions, Processor, ReadinessIndex, Rendition, RenditionError, RenditionFailure, RenditionFailureReason, RenditionHandler, RenditionHandlerOptions, RenditionRequest, RenditionServer, RenditionServerOptions, ResultStore, SourceResolution, SourceStore, StoreCallOptions, createLocalCoordinator, createMemoryReadinessIndex, createRenditionHandler, createRenditionServer };
|
package/dist/server.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { i as formatExtensions, s as assertInteger } from "./recipe-
|
|
2
|
-
import {
|
|
1
|
+
import { i as formatExtensions, s as assertInteger } from "./recipe-BavgOq0b.mjs";
|
|
2
|
+
import { t as hashRecipe } from "./hash-CHipV1N6.mjs";
|
|
3
3
|
import { n as isSafePath } from "./path-hNFUjCbA.mjs";
|
|
4
|
+
import { n as isDeclaredScope } from "./scopes-C1kzqQHz.mjs";
|
|
4
5
|
import { t as RenditionError } from "./error-DViVpVmH.mjs";
|
|
5
|
-
import { t as createRenditionHandler } from "./handler-
|
|
6
|
+
import { t as createRenditionHandler } from "./handler-D5toFJ55.mjs";
|
|
6
7
|
//#region src/server/coordinator.ts
|
|
7
8
|
/** Creates a coordinator that merges concurrent equal requests within the process. */
|
|
8
9
|
function createLocalCoordinator() {
|
|
@@ -84,10 +85,10 @@ const MAX_TIMEOUT = 2 ** 31 - 1;
|
|
|
84
85
|
/**
|
|
85
86
|
* Creates a rendition server over source and result stores.
|
|
86
87
|
*
|
|
87
|
-
* @throws {RangeError} When a limit is not an integer in its range.
|
|
88
|
+
* @throws {RangeError} When a limit is not an integer in its range, or the processor cannot render a variant of the catalog.
|
|
88
89
|
*/
|
|
89
90
|
function createRenditionServer(options) {
|
|
90
|
-
const { source, results, processor, resolveSource = resolveUnscoped, onError } = options;
|
|
91
|
+
const { catalog, source, results, processor, resolveSource = resolveUnscoped, onError } = options;
|
|
91
92
|
const memory = createMemoryReadinessIndex(options.readiness?.memory);
|
|
92
93
|
const shared = options.readiness?.shared;
|
|
93
94
|
const coordinator = options.coordinator ?? createLocalCoordinator();
|
|
@@ -102,13 +103,29 @@ function createRenditionServer(options) {
|
|
|
102
103
|
queueSize,
|
|
103
104
|
queueTimeout
|
|
104
105
|
});
|
|
105
|
-
|
|
106
|
+
for (const variant of catalog.allVariants) try {
|
|
107
|
+
processor.assertRecipe?.(variant.recipe);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
throw error instanceof RangeError ? new RangeError(`The processor cannot render "${variant.label}": ${error.message}`, { cause: error }) : error;
|
|
110
|
+
}
|
|
111
|
+
async function resolve(target, context) {
|
|
112
|
+
if (!isDeclaredScope(catalog.scopes, target.scope)) throw new RenditionError("invalid-request", `The scope of the target ${describe(target)} is not declared.`);
|
|
106
113
|
const resolution = await resolveSource(target, context);
|
|
107
114
|
if (!resolution) throw new RenditionError("invalid-request", `The target ${describe(target)} is not served.`);
|
|
108
|
-
const
|
|
109
|
-
|
|
115
|
+
const { sourceKey } = resolution;
|
|
116
|
+
const resultPrefix = resolution.resultPrefix ?? sourceKey;
|
|
117
|
+
if (!isSafePath(sourceKey) || !isSafePath(resultPrefix)) throw createUnsafeKeyError(target);
|
|
110
118
|
return {
|
|
111
|
-
sourceKey
|
|
119
|
+
sourceKey,
|
|
120
|
+
resultPrefix
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
async function locate(hash, format, target, context) {
|
|
124
|
+
const { sourceKey, resultPrefix } = await resolve(target, context);
|
|
125
|
+
const key = `${resultPrefix}/${hash}.${formatExtensions[format]}`;
|
|
126
|
+
if (!isSafePath(key)) throw createUnsafeKeyError(target);
|
|
127
|
+
return {
|
|
128
|
+
sourceKey,
|
|
112
129
|
rendition: {
|
|
113
130
|
key,
|
|
114
131
|
url: results.url(key)
|
|
@@ -141,27 +158,29 @@ function createRenditionServer(options) {
|
|
|
141
158
|
Promise.resolve(onError?.(error)).catch(() => {});
|
|
142
159
|
} catch {}
|
|
143
160
|
}
|
|
144
|
-
function
|
|
161
|
+
function callStorage(reason, call) {
|
|
145
162
|
return withTimeout(storageTimeout, call).catch((error) => {
|
|
146
163
|
throw error instanceof RenditionError ? error : new RenditionError(reason, `Storage call failed: ${error}`, { cause: error });
|
|
147
164
|
});
|
|
148
165
|
}
|
|
149
166
|
async function render(recipe, sourceKey, key) {
|
|
150
|
-
const data = await
|
|
167
|
+
const data = await callStorage("source-error", (signal) => source.read(sourceKey, { signal }));
|
|
151
168
|
if (!data) throw new RenditionError("source-not-found", `The source ${sourceKey} does not exist.`);
|
|
152
169
|
const image = await processor.process(data, recipe).catch((error) => {
|
|
153
170
|
throw error instanceof RenditionError ? error : new RenditionError("processing-failed", `Processing failed: ${error}`, { cause: error });
|
|
154
171
|
});
|
|
155
|
-
await
|
|
172
|
+
await callStorage("storage-error", (signal) => results.write(key, image, { signal }));
|
|
156
173
|
}
|
|
157
174
|
return {
|
|
175
|
+
catalog,
|
|
158
176
|
async ensure(input, target, context) {
|
|
159
177
|
const recipe = Object.freeze({ ...input });
|
|
178
|
+
processor.assertRecipe?.(recipe);
|
|
160
179
|
const { sourceKey, rendition } = await locate(hashRecipe(recipe), recipe.format, target, context);
|
|
161
180
|
if (await isIndexed(rendition.url)) return rendition;
|
|
162
181
|
return coordinator.run(rendition.url, async () => {
|
|
163
182
|
if (!await isIndexed(rendition.url)) {
|
|
164
|
-
if (!await
|
|
183
|
+
if (!await callStorage("storage-error", (signal) => results.exists(rendition.key, { signal }))) await limiter.run(() => render(recipe, sourceKey, rendition.key));
|
|
165
184
|
await markPublished(rendition.url);
|
|
166
185
|
}
|
|
167
186
|
return rendition;
|
|
@@ -170,7 +189,8 @@ function createRenditionServer(options) {
|
|
|
170
189
|
async findPublished(hash, format, target, context) {
|
|
171
190
|
const { rendition } = await locate(hash, format, target, context);
|
|
172
191
|
return await isIndexed(rendition.url) ? rendition : void 0;
|
|
173
|
-
}
|
|
192
|
+
},
|
|
193
|
+
resolve
|
|
174
194
|
};
|
|
175
195
|
}
|
|
176
196
|
/**
|
|
@@ -191,5 +211,8 @@ function resolveUnscoped({ scope, path }) {
|
|
|
191
211
|
function describe({ scope, path }) {
|
|
192
212
|
return scope === void 0 ? path : `${scope}:${path}`;
|
|
193
213
|
}
|
|
214
|
+
function createUnsafeKeyError(target) {
|
|
215
|
+
return new RenditionError("invalid-request", `The target ${describe(target)} resolves to an unsafe key.`);
|
|
216
|
+
}
|
|
194
217
|
//#endregion
|
|
195
218
|
export { RenditionError, createLocalCoordinator, createMemoryReadinessIndex, createRenditionHandler, createRenditionServer };
|
package/dist/sharp.d.mts
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
import { t as Processor } from "./processor-
|
|
1
|
+
import { t as Processor } from "./processor-DvyPs-OZ.mjs";
|
|
2
2
|
//#region src/processors/sharp/processor.d.ts
|
|
3
3
|
/** Options of {@link createSharpProcessor}. */
|
|
4
4
|
export interface SharpProcessorOptions {
|
|
5
|
-
/** Largest decoded source, in pixels; larger sources
|
|
5
|
+
/** Largest decoded source, in pixels; larger sources fail as too large, and `0` removes the limit. */
|
|
6
6
|
maxInputPixels?: number;
|
|
7
|
+
/**
|
|
8
|
+
* Largest animation a recipe keeps, in pixels of all its frames at the larger of the source and the rendition size; `0` removes the limit.
|
|
9
|
+
*
|
|
10
|
+
* A larger animation renders its first frame.
|
|
11
|
+
*/
|
|
12
|
+
maxAnimationPixels?: number;
|
|
13
|
+
/** Most frames of an animation a recipe keeps; an animation with more renders its first frame, and `0` removes the limit. */
|
|
14
|
+
maxFrames?: number;
|
|
7
15
|
}
|
|
8
|
-
/**
|
|
9
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Creates a processor backed by sharp and libvips.
|
|
18
|
+
*
|
|
19
|
+
* @throws {RangeError} When a limit is not a non-negative integer.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createSharpProcessor({ maxInputPixels, maxAnimationPixels, maxFrames }?: SharpProcessorOptions): Processor;
|
|
10
22
|
//#endregion
|
package/dist/sharp.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { r as formatContentTypes, t as MAX_DIMENSION } from "./recipe-
|
|
1
|
+
import { r as formatContentTypes, s as assertInteger, t as MAX_DIMENSION } from "./recipe-BavgOq0b.mjs";
|
|
2
2
|
import { t as RenditionError } from "./error-DViVpVmH.mjs";
|
|
3
3
|
import sharp from "sharp";
|
|
4
4
|
//#region src/processors/sharp/resize.ts
|
|
@@ -18,7 +18,7 @@ function planResize(recipe, width, height) {
|
|
|
18
18
|
withoutEnlargement: !recipe.enlarge
|
|
19
19
|
};
|
|
20
20
|
const sized = recipe.width !== void 0 || recipe.height !== void 0;
|
|
21
|
-
const scale =
|
|
21
|
+
const scale = computeResizeScale(recipe, width, height) ?? 1;
|
|
22
22
|
const factor = recipe.enlarge ? scale : Math.min(scale, 1);
|
|
23
23
|
const longest = Math.max(width, height);
|
|
24
24
|
if (Math.round(longest * factor) <= 16383 || recipe.fit === "fill") return { resize: sized ? options : void 0 };
|
|
@@ -66,60 +66,98 @@ function planResize(recipe, width, height) {
|
|
|
66
66
|
* Returns `undefined` when the recipe sets no size or the source size is unknown.
|
|
67
67
|
* For `fill`, it is the factor of the side that needs more scaling.
|
|
68
68
|
*/
|
|
69
|
-
function
|
|
69
|
+
function computeResizeScale({ width, height, fit }, sourceWidth, sourceHeight) {
|
|
70
70
|
const scales = [width && sourceWidth ? width / sourceWidth : void 0, height && sourceHeight ? height / sourceHeight : void 0].filter((scale) => scale !== void 0);
|
|
71
71
|
if (scales.length === 0) return;
|
|
72
72
|
return fit === "inside" || fit === "contain" ? Math.min(...scales) : Math.max(...scales);
|
|
73
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Estimates an upper bound of the rendition area of a source of the given size, in pixels.
|
|
76
|
+
*
|
|
77
|
+
* `contain` pads the image to the box, `cover`, `fill` and `inside` keep each side within the box, and other recipes keep the scaled size of the source.
|
|
78
|
+
*/
|
|
79
|
+
function estimateRenditionArea(recipe, width, height) {
|
|
80
|
+
if (recipe.fit === "contain") return recipe.width * recipe.height;
|
|
81
|
+
const scale = computeResizeScale(recipe, width, height) ?? 1;
|
|
82
|
+
const factor = recipe.enlarge ? scale : Math.min(scale, 1);
|
|
83
|
+
const scaledWidth = Math.ceil(width * factor);
|
|
84
|
+
const scaledHeight = Math.ceil(height * factor);
|
|
85
|
+
return recipe.fit === void 0 || recipe.fit === "outside" ? scaledWidth * scaledHeight : Math.min(scaledWidth, recipe.width) * Math.min(scaledHeight, recipe.height);
|
|
86
|
+
}
|
|
74
87
|
/** Converts a normalized `#rrggbbaa` color to the form sharp accepts. */
|
|
75
88
|
function parseColor(color) {
|
|
76
|
-
const
|
|
89
|
+
const parseChannel = (index) => Number.parseInt(color.slice(1 + index * 2, 3 + index * 2), 16);
|
|
77
90
|
return {
|
|
78
|
-
r:
|
|
79
|
-
g:
|
|
80
|
-
b:
|
|
81
|
-
alpha:
|
|
91
|
+
r: parseChannel(0),
|
|
92
|
+
g: parseChannel(1),
|
|
93
|
+
b: parseChannel(2),
|
|
94
|
+
alpha: parseChannel(3) / 255
|
|
82
95
|
};
|
|
83
96
|
}
|
|
84
97
|
//#endregion
|
|
85
98
|
//#region src/processors/sharp/processor.ts
|
|
86
99
|
/** Rasterization density SVG renders at when no scaling is needed, in DPI. */
|
|
87
100
|
const SVG_BASE_DENSITY = 72;
|
|
88
|
-
/**
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Creates a processor backed by sharp and libvips.
|
|
103
|
+
*
|
|
104
|
+
* @throws {RangeError} When a limit is not a non-negative integer.
|
|
105
|
+
*/
|
|
106
|
+
function createSharpProcessor({ maxInputPixels = 1e8, maxAnimationPixels = 5e7, maxFrames = 1e3 } = {}) {
|
|
107
|
+
assertInteger("maxInputPixels", maxInputPixels, 0, Number.MAX_SAFE_INTEGER);
|
|
108
|
+
assertInteger("maxAnimationPixels", maxAnimationPixels, 0, Number.MAX_SAFE_INTEGER);
|
|
109
|
+
assertInteger("maxFrames", maxFrames, 0, Number.MAX_SAFE_INTEGER);
|
|
110
|
+
const limits = {
|
|
111
|
+
maxAnimationPixels,
|
|
112
|
+
maxFrames
|
|
113
|
+
};
|
|
114
|
+
return {
|
|
115
|
+
assertRecipe,
|
|
116
|
+
async process(source, recipe) {
|
|
117
|
+
assertRecipe(recipe);
|
|
118
|
+
const data = await render(source, recipe, maxInputPixels, limits).catch((error) => {
|
|
119
|
+
throw error instanceof RenditionError ? error : new RenditionError("unsupported-source", "The source cannot be processed.", { cause: error });
|
|
120
|
+
});
|
|
121
|
+
return {
|
|
122
|
+
data: new Uint8Array(data),
|
|
123
|
+
contentType: formatContentTypes[recipe.format]
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
};
|
|
99
127
|
}
|
|
100
|
-
async function render(source, recipe, maxInputPixels) {
|
|
128
|
+
async function render(source, recipe, maxInputPixels, limits) {
|
|
101
129
|
const input = {
|
|
102
130
|
failOn: "error",
|
|
103
131
|
limitInputPixels: maxInputPixels
|
|
104
132
|
};
|
|
105
|
-
const metadata = await sharp(source,
|
|
133
|
+
const metadata = await sharp(source, {
|
|
134
|
+
...input,
|
|
135
|
+
limitInputPixels: false
|
|
136
|
+
}).metadata();
|
|
137
|
+
assertPixels(metadata.width, metadata.height, maxInputPixels);
|
|
106
138
|
let { width, height } = metadata.autoOrient;
|
|
107
139
|
let density;
|
|
108
140
|
if (metadata.format === "svg") {
|
|
109
|
-
const needed =
|
|
141
|
+
const needed = computeResizeScale(recipe, width, height) ?? 1;
|
|
110
142
|
const scale = recipe.fit === "cover" || recipe.fit === "fill" ? needed : Math.min(needed, MAX_DIMENSION / Math.max(width, height, 1));
|
|
111
143
|
density = Math.min(Math.max(SVG_BASE_DENSITY * scale, 1), 1e5);
|
|
112
144
|
const raster = (await sharp(source, {
|
|
113
145
|
...input,
|
|
114
|
-
density
|
|
146
|
+
density,
|
|
147
|
+
limitInputPixels: false
|
|
115
148
|
}).metadata()).autoOrient;
|
|
149
|
+
assertPixels(raster.width, raster.height, maxInputPixels);
|
|
116
150
|
width = raster.width;
|
|
117
151
|
height = raster.height;
|
|
118
152
|
}
|
|
153
|
+
const frames = keepsAnimation(recipe, metadata, limits) ? {
|
|
154
|
+
animated: true,
|
|
155
|
+
limitInputPixels: limits.maxAnimationPixels
|
|
156
|
+
} : { pages: 1 };
|
|
119
157
|
let pipeline = sharp(source, {
|
|
120
158
|
...input,
|
|
121
159
|
density,
|
|
122
|
-
|
|
160
|
+
...frames
|
|
123
161
|
}).autoOrient();
|
|
124
162
|
const { extract, resize } = planResize(recipe, width, height);
|
|
125
163
|
if (extract) pipeline = pipeline.extract(extract);
|
|
@@ -139,5 +177,29 @@ async function render(source, recipe, maxInputPixels) {
|
|
|
139
177
|
case "png": return pipeline.png().toBuffer();
|
|
140
178
|
}
|
|
141
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Whether a rendition keeps the animation of a source, judged by the metadata of its first frame.
|
|
182
|
+
*
|
|
183
|
+
* Sharp resizes and crops the frames one by one, so the planned geometry of the first frame applies to every frame.
|
|
184
|
+
*/
|
|
185
|
+
function keepsAnimation(recipe, { format, pages = 1, width, height, orientation = 1 }, { maxAnimationPixels, maxFrames }) {
|
|
186
|
+
if (recipe.animation !== "preserve" || format !== "gif" && format !== "webp" || pages < 2 || orientation !== 1) return false;
|
|
187
|
+
const pixels = Math.max(width * height, estimateRenditionArea(recipe, width, height)) * pages;
|
|
188
|
+
return (maxFrames === 0 || pages <= maxFrames) && (maxAnimationPixels === 0 || pixels <= maxAnimationPixels);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Checks that sharp can keep an animation where a recipe asks for it.
|
|
192
|
+
*
|
|
193
|
+
* @throws {RangeError} When the recipe keeps an animation in a format other than `webp` or crops it by content.
|
|
194
|
+
*/
|
|
195
|
+
function assertRecipe({ animation, format, position }) {
|
|
196
|
+
if (animation !== "preserve") return;
|
|
197
|
+
if (format !== "webp") throw new RangeError(`Animations cannot be encoded as ${format}.`);
|
|
198
|
+
if (position === "entropy" || position === "attention") throw new RangeError(`Position "${position}" cannot crop an animation.`);
|
|
199
|
+
}
|
|
200
|
+
/** Fails as `source-too-large` when an image of the size exceeds the pixel limit, unless the limit is `0`. */
|
|
201
|
+
function assertPixels(width, height, maxInputPixels) {
|
|
202
|
+
if (maxInputPixels > 0 && width * height > maxInputPixels) throw new RenditionError("source-too-large", `The source of ${width}×${height} pixels exceeds the limit of ${maxInputPixels}.`);
|
|
203
|
+
}
|
|
142
204
|
//#endregion
|
|
143
205
|
export { createSharpProcessor };
|
|
@@ -20,7 +20,7 @@ interface SourceStore {
|
|
|
20
20
|
* Reads a source object.
|
|
21
21
|
*
|
|
22
22
|
* Resolves to `undefined` when the object does not exist.
|
|
23
|
-
* Rejects with a `RenditionError` of reason `
|
|
23
|
+
* Rejects with a `RenditionError` of reason `source-too-large` when the object exceeds a size limit.
|
|
24
24
|
*/
|
|
25
25
|
read(key: string, options: StoreCallOptions): Promise<Uint8Array | undefined>;
|
|
26
26
|
}
|