image-renditions 0.0.0-stage → 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.
package/dist/sharp.mjs ADDED
@@ -0,0 +1,205 @@
1
+ import { r as formatContentTypes, s as assertInteger, t as MAX_DIMENSION } from "./recipe-BavgOq0b.mjs";
2
+ import { t as RenditionError } from "./error-DViVpVmH.mjs";
3
+ import sharp from "sharp";
4
+ //#region src/processors/sharp/resize.ts
5
+ /**
6
+ * Plans the crop and resize of a source of the given size; an absent step is skipped.
7
+ *
8
+ * An image the recipe would scale beyond {@link MAX_DIMENSION} on a side is scaled less, so it stays whole and comes out smaller than the recipe box.
9
+ * Under `cover`, the long side is cut before scaling instead, so the rendition keeps the box.
10
+ */
11
+ function planResize(recipe, width, height) {
12
+ const options = {
13
+ width: recipe.width,
14
+ height: recipe.height,
15
+ fit: recipe.fit,
16
+ position: recipe.position,
17
+ background: recipe.background && parseColor(recipe.background),
18
+ withoutEnlargement: !recipe.enlarge
19
+ };
20
+ const sized = recipe.width !== void 0 || recipe.height !== void 0;
21
+ const scale = computeResizeScale(recipe, width, height) ?? 1;
22
+ const factor = recipe.enlarge ? scale : Math.min(scale, 1);
23
+ const longest = Math.max(width, height);
24
+ if (Math.round(longest * factor) <= 16383 || recipe.fit === "fill") return { resize: sized ? options : void 0 };
25
+ if (recipe.fit === "cover") {
26
+ if (factor <= 1) return { resize: options };
27
+ const vertical = height > width;
28
+ const across = vertical ? width : height;
29
+ const boxAcross = vertical ? recipe.width : recipe.height;
30
+ const boxAlong = vertical ? recipe.height : recipe.width;
31
+ const fitting = Math.floor(MAX_DIMENSION * across / boxAcross);
32
+ const start = recipe.position === (vertical ? "top" : "left");
33
+ const end = recipe.position === (vertical ? "bottom" : "right");
34
+ const length = !start && !end && (longest - fitting) % 2 === 1 && (fitting - 1) * boxAcross >= boxAlong * across ? fitting - 1 : fitting;
35
+ const slack = longest - length;
36
+ const offset = start ? 0 : end ? slack : Math.floor(slack / 2);
37
+ return {
38
+ extract: vertical ? {
39
+ left: 0,
40
+ top: offset,
41
+ width,
42
+ height: length
43
+ } : {
44
+ left: offset,
45
+ top: 0,
46
+ width: length,
47
+ height
48
+ },
49
+ resize: options
50
+ };
51
+ }
52
+ const box = recipe.fit === "outside" ? {} : {
53
+ width: recipe.width,
54
+ height: recipe.height
55
+ };
56
+ return { resize: {
57
+ width: box.width ?? 16383,
58
+ height: box.height ?? 16383,
59
+ fit: "inside",
60
+ withoutEnlargement: !recipe.enlarge
61
+ } };
62
+ }
63
+ /**
64
+ * Factor that brings a source of the given size to the recipe box, before any `enlarge` limit.
65
+ *
66
+ * Returns `undefined` when the recipe sets no size or the source size is unknown.
67
+ * For `fill`, it is the factor of the side that needs more scaling.
68
+ */
69
+ function computeResizeScale({ width, height, fit }, sourceWidth, sourceHeight) {
70
+ const scales = [width && sourceWidth ? width / sourceWidth : void 0, height && sourceHeight ? height / sourceHeight : void 0].filter((scale) => scale !== void 0);
71
+ if (scales.length === 0) return;
72
+ return fit === "inside" || fit === "contain" ? Math.min(...scales) : Math.max(...scales);
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
+ }
87
+ /** Converts a normalized `#rrggbbaa` color to the form sharp accepts. */
88
+ function parseColor(color) {
89
+ const parseChannel = (index) => Number.parseInt(color.slice(1 + index * 2, 3 + index * 2), 16);
90
+ return {
91
+ r: parseChannel(0),
92
+ g: parseChannel(1),
93
+ b: parseChannel(2),
94
+ alpha: parseChannel(3) / 255
95
+ };
96
+ }
97
+ //#endregion
98
+ //#region src/processors/sharp/processor.ts
99
+ /** Rasterization density SVG renders at when no scaling is needed, in DPI. */
100
+ const SVG_BASE_DENSITY = 72;
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
+ };
127
+ }
128
+ async function render(source, recipe, maxInputPixels, limits) {
129
+ const input = {
130
+ failOn: "error",
131
+ limitInputPixels: maxInputPixels
132
+ };
133
+ const metadata = await sharp(source, {
134
+ ...input,
135
+ limitInputPixels: false
136
+ }).metadata();
137
+ assertPixels(metadata.width, metadata.height, maxInputPixels);
138
+ let { width, height } = metadata.autoOrient;
139
+ let density;
140
+ if (metadata.format === "svg") {
141
+ const needed = computeResizeScale(recipe, width, height) ?? 1;
142
+ const scale = recipe.fit === "cover" || recipe.fit === "fill" ? needed : Math.min(needed, MAX_DIMENSION / Math.max(width, height, 1));
143
+ density = Math.min(Math.max(SVG_BASE_DENSITY * scale, 1), 1e5);
144
+ const raster = (await sharp(source, {
145
+ ...input,
146
+ density,
147
+ limitInputPixels: false
148
+ }).metadata()).autoOrient;
149
+ assertPixels(raster.width, raster.height, maxInputPixels);
150
+ width = raster.width;
151
+ height = raster.height;
152
+ }
153
+ const frames = keepsAnimation(recipe, metadata, limits) ? {
154
+ animated: true,
155
+ limitInputPixels: limits.maxAnimationPixels
156
+ } : { pages: 1 };
157
+ let pipeline = sharp(source, {
158
+ ...input,
159
+ density,
160
+ ...frames
161
+ }).autoOrient();
162
+ const { extract, resize } = planResize(recipe, width, height);
163
+ if (extract) pipeline = pipeline.extract(extract);
164
+ if (resize) pipeline = pipeline.resize(resize);
165
+ if (recipe.format === "jpeg") pipeline = pipeline.flatten({ background: parseColor(recipe.background) });
166
+ pipeline = pipeline.toColourspace("srgb");
167
+ switch (recipe.format) {
168
+ case "avif": return pipeline.avif({
169
+ quality: recipe.quality,
170
+ effort: recipe.effort
171
+ }).toBuffer();
172
+ case "webp": return pipeline.webp({
173
+ quality: recipe.quality,
174
+ effort: recipe.effort
175
+ }).toBuffer();
176
+ case "jpeg": return pipeline.jpeg({ quality: recipe.quality }).toBuffer();
177
+ case "png": return pipeline.png().toBuffer();
178
+ }
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
+ }
204
+ //#endregion
205
+ export { createSharpProcessor };
@@ -0,0 +1,42 @@
1
+ //#region src/server/stores.d.ts
2
+ /** Encoded rendition ready to be stored. */
3
+ interface EncodedImage {
4
+ data: Uint8Array;
5
+ /** MIME type of `data`, such as `image/avif`. */
6
+ contentType: string;
7
+ }
8
+ /** Options of a single storage call. */
9
+ interface StoreCallOptions {
10
+ /** Aborts the call when the operation times out. */
11
+ signal: AbortSignal;
12
+ }
13
+ /**
14
+ * Storage of immutable source images.
15
+ *
16
+ * Sources are assumed never to change under the same key: a rendition is generated once and is not refreshed.
17
+ */
18
+ interface SourceStore {
19
+ /**
20
+ * Reads a source object.
21
+ *
22
+ * Resolves to `undefined` when the object does not exist.
23
+ * Rejects with a `RenditionError` of reason `source-too-large` when the object exceeds a size limit.
24
+ */
25
+ read(key: string, options: StoreCallOptions): Promise<Uint8Array | undefined>;
26
+ }
27
+ /** Storage of published renditions, each served from a public URL. */
28
+ interface ResultStore {
29
+ /** Checks whether an object is published under the key. */
30
+ exists(key: string, options: StoreCallOptions): Promise<boolean>;
31
+ /**
32
+ * Publishes an object under the key.
33
+ *
34
+ * Overwriting an existing object with an equivalent rendition must be safe, because concurrent replicas may render the same key.
35
+ * Readers must never observe a partially written object.
36
+ */
37
+ write(key: string, image: EncodedImage, options: StoreCallOptions): Promise<void>;
38
+ /** Public URL of the object under the key, percent-encoded so it can be sent in a `Location` header. */
39
+ url(key: string): string;
40
+ }
41
+ //#endregion
42
+ export { StoreCallOptions as i, ResultStore as n, SourceStore as r, EncodedImage as t };
@@ -0,0 +1,81 @@
1
+ import { i as formatExtensions, o as serializeRecipe } from "./recipe-BavgOq0b.mjs";
2
+ import { n as isSafePath, r as isSafeScope, t as encodePath } from "./path-hNFUjCbA.mjs";
3
+ import { sha256 } from "@noble/hashes/sha2.js";
4
+ import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
5
+ //#region src/hash.ts
6
+ /** Length of a recipe hash in hex digits, which keeps 128 bits of SHA-256. */
7
+ const RECIPE_HASH_LENGTH = 32;
8
+ /** Identifies a recipe by the truncated SHA-256 of its canonical serialization. */
9
+ function hashRecipe(recipe) {
10
+ return bytesToHex(sha256(utf8ToBytes(serializeRecipe(recipe)))).slice(0, 32);
11
+ }
12
+ //#endregion
13
+ //#region src/url.ts
14
+ /** Version segment of the rendition URL format this library builds and parses. */
15
+ const URL_FORMAT_VERSION = "v1";
16
+ /** Mount path of the rendition handler unless a catalog sets another one. */
17
+ const DEFAULT_BASE_PATH = "/_renditions";
18
+ /**
19
+ * Formats the path of a rendition URL: `<basePath>/v1/[<scope>/]<label>.<hash>.<extension>/<path>`.
20
+ *
21
+ * @throws {RangeError} When a part is unsafe to put into the URL.
22
+ */
23
+ function formatRenditionPath(basePath, { scope, label, hash, format, path }) {
24
+ assertBasePath(basePath);
25
+ if (scope !== void 0 && !isSafeScope(scope)) throw new RangeError(`Invalid rendition scope "${scope}".`);
26
+ const extension = formatExtensions[format];
27
+ if (!isSafeLabel(label) || !isHash(hash) || !extension) throw new RangeError(`Invalid rendition variant "${label}.${hash}.${extension ?? format}".`);
28
+ if (!isSafePath(path)) throw new RangeError(`Unsafe rendition source path "${path}".`);
29
+ return `${basePath}/v1/${scope === void 0 ? "" : `${scope}/`}${label}.${hash}.${extension}/${encodePath(path)}`;
30
+ }
31
+ /**
32
+ * Parses the path of a rendition URL, ignoring any query string.
33
+ *
34
+ * Returns `undefined` for a path outside `basePath`, another URL format version, or malformed or unsafe parts.
35
+ */
36
+ function parseRenditionPath(basePath, url) {
37
+ const prefix = `${basePath}/v1/`;
38
+ const pathname = url.split("?", 1)[0];
39
+ if (!pathname.startsWith(prefix)) return;
40
+ const parts = pathname.slice(prefix.length).split("/");
41
+ const scope = isSafeScope(parts[0]) ? parts.shift() : void 0;
42
+ const [variant, ...encodedSegments] = parts;
43
+ if (!variant) return;
44
+ const [, label = "", hash = "", extension] = /^(.+)\.([^.]+)\.([^.]+)$/.exec(variant) ?? [];
45
+ const format = formats.find((format) => formatExtensions[format] === extension);
46
+ if (!isSafeLabel(label) || !isHash(hash) || !format) return;
47
+ const segments = encodedSegments.map(decodeSegment);
48
+ if (segments.some((segment) => segment === void 0 || segment.includes("/"))) return;
49
+ const path = segments.join("/");
50
+ return isSafePath(path) ? {
51
+ scope,
52
+ label,
53
+ hash,
54
+ format,
55
+ path
56
+ } : void 0;
57
+ }
58
+ /**
59
+ * Checks that a base path is absolute, has no trailing slash and needs no URL encoding.
60
+ *
61
+ * @throws {RangeError} When the base path is invalid.
62
+ */
63
+ function assertBasePath(basePath) {
64
+ if (!/^(?:\/[\w.~-]+)+$/.test(basePath)) throw new RangeError(`Invalid rendition base path "${basePath}".`);
65
+ }
66
+ const formats = Object.keys(formatExtensions);
67
+ function isSafeLabel(label) {
68
+ return /^[\w@.-]{1,64}$/.test(label);
69
+ }
70
+ function isHash(hash) {
71
+ return hash.length === 32 && /^[\da-f]+$/.test(hash);
72
+ }
73
+ function decodeSegment(segment) {
74
+ try {
75
+ return decodeURIComponent(segment);
76
+ } catch {
77
+ return;
78
+ }
79
+ }
80
+ //#endregion
81
+ export { parseRenditionPath as a, formatRenditionPath as i, URL_FORMAT_VERSION as n, RECIPE_HASH_LENGTH as o, assertBasePath as r, hashRecipe as s, DEFAULT_BASE_PATH as t };
@@ -0,0 +1,38 @@
1
+ import { s as RenditionFormat } from "./recipe-BT8D60Qb.mjs";
2
+ //#region src/url.d.ts
3
+ /** Version segment of the rendition URL format this library builds and parses. */
4
+ declare const URL_FORMAT_VERSION = "v1";
5
+ /** Mount path of the rendition handler unless a catalog sets another one. */
6
+ declare const DEFAULT_BASE_PATH = "/_renditions";
7
+ /** Parts of a rendition URL. */
8
+ interface RenditionAddress {
9
+ /** Application-defined space the logical path belongs to, such as a tenant. */
10
+ scope?: string;
11
+ /** Human-readable variant name; the server ignores it and resolves the recipe by `hash`. */
12
+ label: string;
13
+ hash: string;
14
+ /** Output format, which also lets a server locate a rendition whose recipe it no longer knows. */
15
+ format: RenditionFormat;
16
+ /** Logical source path inside the scope. */
17
+ path: string;
18
+ }
19
+ /**
20
+ * Formats the path of a rendition URL: `<basePath>/v1/[<scope>/]<label>.<hash>.<extension>/<path>`.
21
+ *
22
+ * @throws {RangeError} When a part is unsafe to put into the URL.
23
+ */
24
+ declare function formatRenditionPath(basePath: string, { scope, label, hash, format, path }: RenditionAddress): string;
25
+ /**
26
+ * Parses the path of a rendition URL, ignoring any query string.
27
+ *
28
+ * Returns `undefined` for a path outside `basePath`, another URL format version, or malformed or unsafe parts.
29
+ */
30
+ declare function parseRenditionPath(basePath: string, url: string): RenditionAddress | undefined;
31
+ /**
32
+ * Checks that a base path is absolute, has no trailing slash and needs no URL encoding.
33
+ *
34
+ * @throws {RangeError} When the base path is invalid.
35
+ */
36
+ declare function assertBasePath(basePath: string): void;
37
+ //#endregion
38
+ export { formatRenditionPath as a, assertBasePath as i, RenditionAddress as n, parseRenditionPath as o, URL_FORMAT_VERSION as r, DEFAULT_BASE_PATH as t };
package/package.json CHANGED
@@ -1,6 +1,91 @@
1
1
  {
2
2
  "name": "image-renditions",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "On-demand image transformations that persist every result to durable storage and keep app servers stateless.",
5
+ "homepage": "https://github.com/IlyaSemenov/image-renditions#readme",
6
+ "bugs": "https://github.com/IlyaSemenov/image-renditions/issues",
7
+ "license": "MIT",
8
+ "author": "Ilya Semenov",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/IlyaSemenov/image-renditions.git"
12
+ },
13
+ "files": [
14
+ "README.md",
15
+ "dist"
16
+ ],
17
+ "type": "module",
18
+ "types": "./dist/index.d.mts",
19
+ "exports": {
20
+ ".": "./dist/index.mjs",
21
+ "./fs": "./dist/fs.mjs",
22
+ "./h3": "./dist/h3.mjs",
23
+ "./nuxt": "./dist/nuxt.mjs",
24
+ "./nuxt/runtime": "./dist/nuxt/runtime.mjs",
25
+ "./s3": "./dist/s3.mjs",
26
+ "./server": "./dist/server.mjs",
27
+ "./sharp": "./dist/sharp.mjs",
28
+ "./package.json": "./package.json"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public",
32
+ "provenance": true
33
+ },
34
+ "scripts": {
35
+ "build": "tsdown",
36
+ "lint": "oxlint --fix && oxfmt",
37
+ "lint:check": "oxlint && oxfmt --check",
38
+ "prepare": "lefthook install",
39
+ "prepublishOnly": "bun run build",
40
+ "test": "bun test",
41
+ "test:nuxt": "bun run build && bun tests/nuxt-build.ts",
42
+ "types": "tsc && tsc -p tests/tsconfig.json && tsc -p tests/h3-v1/tsconfig.json"
43
+ },
44
+ "dependencies": {
45
+ "@noble/hashes": "^2.4.0",
46
+ "jiti": "^2.7.0"
47
+ },
48
+ "devDependencies": {
49
+ "@arethetypeswrong/core": "^0.18.5",
50
+ "@aws-sdk/client-s3": "^3.1150.0",
51
+ "@changesets/cli": "^3.0.3",
52
+ "@ilyasemenov/oxc-config": "^0.1.1",
53
+ "@tsconfig/bun": "^1.0.11",
54
+ "@types/bun": "^1.4.3",
55
+ "h3": "^2.0.2",
56
+ "h3-v1": "npm:h3@^1.15.11",
57
+ "nuxt": "^4.6.1",
58
+ "oxfmt": "^0.73.0",
59
+ "oxlint": "^1.88.0",
60
+ "publint": "^0.3.25",
61
+ "sharp": "^0.35.5",
62
+ "tsdown": "^0.23.1",
63
+ "typescript": "^7.0.2",
64
+ "vue": "^3.5.44"
65
+ },
66
+ "peerDependencies": {
67
+ "@aws-sdk/client-s3": "^3.1150.0",
68
+ "h3": "^1.15.0 || ^2.0.0",
69
+ "nuxt": "^4.0.0",
70
+ "sharp": "^0.35.0",
71
+ "vue": "^3.5.0"
72
+ },
73
+ "peerDependenciesMeta": {
74
+ "@aws-sdk/client-s3": {
75
+ "optional": true
76
+ },
77
+ "h3": {
78
+ "optional": true
79
+ },
80
+ "nuxt": {
81
+ "optional": true
82
+ },
83
+ "sharp": {
84
+ "optional": true
85
+ },
86
+ "vue": {
87
+ "optional": true
88
+ }
89
+ },
90
+ "packageManager": "bun@1.4.3"
91
+ }