image-renditions 0.1.0 → 0.2.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.
@@ -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: SourcePolicy["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 { RenditionFit as a, RenditionPosition as c, formatExtensions as d, normalizeRecipe as f, RecipeInput as i, SourcePolicy as l, RECIPE_ENGINE_VERSION as n, RenditionFormat as o, serializeRecipe as p, Recipe as r, RenditionOptions as s, MAX_DIMENSION as t, formatContentTypes as u };
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: policyValue("svg", policy, ["rasterize"]),
109
- animation: policyValue("animation", policy, ["first-frame"]),
110
- orientation: policyValue("orientation", policy, ["auto"])
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 policyValue(name, policy, allowed) {
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-DFDRMZcC.mjs";
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
- client: S3ClientLike;
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 are unsupported. */
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
- client: S3ClientLike;
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.getObject({
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("unsupported-source", `The source ${key} exceeds ${maxBytes} bytes.`);
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.headObject({
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.putObject({
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-BFV_vIr4.mjs";
2
- import { i as StoreCallOptions, n as ResultStore, r as SourceStore, t as EncodedImage } from "./stores-DFDRMZcC.mjs";
3
- import { t as Processor } from "./processor-_xuwB7Aa.mjs";
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-9XP5SZhg.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-DmOhQJWE.mjs";
2
- import { s as hashRecipe } from "./url-BIvAw2BR.mjs";
1
+ import { i as formatExtensions, s as assertInteger } from "./recipe-BavgOq0b.mjs";
2
+ import { s as hashRecipe } from "./url-BAU5GYXC.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-BHMwNZnI.mjs";
6
+ import { t as createRenditionHandler } from "./handler-CPJllrYi.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
- async function locate(hash, format, target, context) {
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 key = `${resolution.resultPrefix ?? resolution.sourceKey}/${hash}.${formatExtensions[format]}`;
109
- if (!isSafePath(resolution.sourceKey) || !isSafePath(key)) throw new RenditionError("invalid-request", `The target ${describe(target)} resolves to an unsafe key.`);
115
+ const { sourceKey } = resolution;
116
+ const resultPrefix = resolution.resultPrefix ?? sourceKey;
117
+ if (!isSafePath(sourceKey) || !isSafePath(resultPrefix)) throw createUnsafeKeyError(target);
110
118
  return {
111
- sourceKey: resolution.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 storageCall(reason, call) {
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 storageCall("source-error", (signal) => source.read(sourceKey, { signal }));
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 storageCall("storage-error", (signal) => results.write(key, image, { signal }));
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 storageCall("storage-error", (signal) => results.exists(rendition.key, { signal }))) await limiter.run(() => render(recipe, sourceKey, rendition.key));
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-_xuwB7Aa.mjs";
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 are unsupported. */
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
- /** Creates a processor backed by sharp and libvips. */
9
- export declare function createSharpProcessor({ maxInputPixels }?: SharpProcessorOptions): Processor;
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-DmOhQJWE.mjs";
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 = resizeScale(recipe, width, height) ?? 1;
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 resizeScale({ width, height, fit }, sourceWidth, sourceHeight) {
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 channel = (index) => Number.parseInt(color.slice(1 + index * 2, 3 + index * 2), 16);
89
+ const parseChannel = (index) => Number.parseInt(color.slice(1 + index * 2, 3 + index * 2), 16);
77
90
  return {
78
- r: channel(0),
79
- g: channel(1),
80
- b: channel(2),
81
- alpha: channel(3) / 255
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
- /** Creates a processor backed by sharp and libvips. */
89
- function createSharpProcessor({ maxInputPixels = 1e8 } = {}) {
90
- return { async process(source, recipe) {
91
- const data = await render(source, recipe, maxInputPixels).catch((error) => {
92
- throw new RenditionError("unsupported-source", "The source cannot be processed.", { cause: error });
93
- });
94
- return {
95
- data: new Uint8Array(data),
96
- contentType: formatContentTypes[recipe.format]
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, input).metadata();
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 = resizeScale(recipe, width, height) ?? 1;
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
- pages: 1
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 `unsupported-source` when the object exceeds a size limit.
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
  }
@@ -1,4 +1,4 @@
1
- import { i as formatExtensions, o as serializeRecipe } from "./recipe-DmOhQJWE.mjs";
1
+ import { i as formatExtensions, o as serializeRecipe } from "./recipe-BavgOq0b.mjs";
2
2
  import { n as isSafePath, r as isSafeScope, t as encodePath } from "./path-hNFUjCbA.mjs";
3
3
  import { sha256 } from "@noble/hashes/sha2.js";
4
4
  import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
@@ -1,4 +1,4 @@
1
- import { o as RenditionFormat } from "./recipe-Je9DxqO1.mjs";
1
+ import { s as RenditionFormat } from "./recipe-BT8D60Qb.mjs";
2
2
  //#region src/url.d.ts
3
3
  /** Version segment of the rendition URL format this library builds and parses. */
4
4
  declare const URL_FORMAT_VERSION = "v1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "image-renditions",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
@@ -64,12 +64,16 @@
64
64
  "vue": "^3.5.44"
65
65
  },
66
66
  "peerDependencies": {
67
+ "@aws-sdk/client-s3": "^3.1150.0",
67
68
  "h3": "^1.15.0 || ^2.0.0",
68
69
  "nuxt": "^4.0.0",
69
70
  "sharp": "^0.35.0",
70
71
  "vue": "^3.5.0"
71
72
  },
72
73
  "peerDependenciesMeta": {
74
+ "@aws-sdk/client-s3": {
75
+ "optional": true
76
+ },
73
77
  "h3": {
74
78
  "optional": true
75
79
  },