image-renditions 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -409,6 +409,18 @@ It renders the first frame of other animated sources, such as APNG and animated
409
409
  An animation at the pixel limit may take about 4 bytes of memory per pixel, and its encoder needs more, so plan memory for `limits.concurrency` such renditions at once.
410
410
  Changing the limits keeps stored renditions as they are; bump `pipelineVersion` to render them again.
411
411
 
412
+ When a preset keeps the animation and a GIF or WebP animation renders its first frame because of its EXIF orientation or a limit, `createRenditionServer()` passes it to `onDegradation` once the rendition is written.
413
+ The reason is `animation-orientation`, `animation-frames` or `animation-pixels`, and the message has the values involved, so you can log such renditions and tune the limits:
414
+
415
+ ```ts
416
+ const server = createRenditionServer({
417
+ // ...
418
+ onDegradation({ message, target }) {
419
+ console.warn(`The rendition of ${target.path} is degraded: ${message}`)
420
+ },
421
+ })
422
+ ```
423
+
412
424
  To process images with another library, implement the [`Processor`](#extending) interface and pass your implementation to `createRenditionServer()` as `processor`.
413
425
 
414
426
  ## h3 and other frameworks
@@ -576,6 +588,7 @@ Implement an interface from `image-renditions/server` and pass your implementati
576
588
  - To use other storage, implement `SourceStore` or `ResultStore` and pass it as `source` or `results`.
577
589
  - To process images with another library, implement `Processor` and pass it as `processor`.
578
590
  Implement its `assertRecipe()` to reject recipes it cannot render, such as animations, when the server is created.
591
+ Return `degradation` from its `process()` to report an image that falls short of its recipe to `onDegradation`.
579
592
  - To share the index of published renditions between replicas, for example in Redis, implement `ReadinessIndex` and pass it as `readiness.shared`.
580
593
  If a call fails or exceeds `indexTimeout`, the server reports the error to `onError` and checks storage instead, while [outdated URLs](#outdated-urls) fail as `unknown-recipe`.
581
594
  - To render each rendition once across replicas, implement `Coordinator` as a shared lock and pass it as `coordinator`.
package/dist/h3.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { r as RenditionHandlerOptions } from "./handler-RAQFWtrg.mjs";
1
+ import { r as RenditionHandlerOptions } from "./handler-P49xsp3S.mjs";
2
2
  import { EventHandler, H3Event } from "h3";
3
3
  //#region src/h3.d.ts
4
4
  /** Source of the application context of a request. */
@@ -2,7 +2,7 @@ import { r as Recipe, s as RenditionFormat } from "./recipe-BT8D60Qb.mjs";
2
2
  import { a as RenditionTarget, t as Catalog } from "./catalog-D11xkViN.mjs";
3
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 { t as Processor } from "./processor-DvyPs-OZ.mjs";
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 { RenditionFailureReason as _, createRenditionHandler as a, RenditionServerOptions as c, MemoryReadinessIndexOptions as d, ReadinessIndex as f, RenditionError as g, createLocalCoordinator as h, RenditionRequest as i, SourceResolution as l, Coordinator as m, RenditionHandler as n, Rendition as o, createMemoryReadinessIndex as p, RenditionHandlerOptions as r, RenditionServer as s, RenditionFailure as t, createRenditionServer as u };
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 };
@@ -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<EncodedImage>;
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 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";
1
+ import { _ as RenditionError, a as createRenditionHandler, c as RenditionServer, d as createRenditionServer, f as MemoryReadinessIndexOptions, g as createLocalCoordinator, h as Coordinator, i as RenditionRequest, l as RenditionServerOptions, m as createMemoryReadinessIndex, n as RenditionHandler, o as Rendition, p as ReadinessIndex, r as RenditionHandlerOptions, s as RenditionDegradation, t as RenditionFailure, u as SourceResolution, v as RenditionFailureReason } from "./handler-P49xsp3S.mjs";
2
2
  import { i as StoreCallOptions, n as ResultStore, r as SourceStore, t as EncodedImage } from "./stores-P-TUmax4.mjs";
3
- import { t as Processor } from "./processor-DvyPs-OZ.mjs";
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
@@ -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
- report(error);
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 data = await callStorage("source-error", (signal) => source.read(sourceKey, { signal }));
168
- if (!data) throw new RenditionError("source-not-found", `The source ${sourceKey} does not exist.`);
169
- const image = await processor.process(data, recipe).catch((error) => {
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, image, { signal }));
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 }))) await limiter.run(() => render(recipe, sourceKey, rendition.key));
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
@@ -1,4 +1,4 @@
1
- import { t as Processor } from "./processor-DvyPs-OZ.mjs";
1
+ import { r as Processor } from "./processor-Dd3jqaOW.mjs";
2
2
  //#region src/processors/sharp/processor.d.ts
3
3
  /** Options of {@link createSharpProcessor}. */
4
4
  export interface SharpProcessorOptions {
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 frames = keepsAnimation(recipe, metadata, limits) ? {
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
- * Whether a rendition keeps the animation of a source, judged by the metadata of its first frame.
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 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;
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
- return (maxFrames === 0 || pages <= maxFrames) && (maxAnimationPixels === 0 || pixels <= maxAnimationPixels);
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "image-renditions",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "On-demand image transformations that persist every result to durable storage and keep app servers stateless.",
5
5
  "homepage": "https://github.com/IlyaSemenov/image-renditions#readme",
6
6
  "bugs": "https://github.com/IlyaSemenov/image-renditions/issues",