Page Menu
Home
Sealhub
Search
Configure Global Search
Log In
Files
F9582602
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
18 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/index.ts b/src/index.ts
index 14b6c9c..b8f61c3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,633 +1,635 @@
import Router from "@koa/router";
import crypto from "crypto";
import { Middleware } from "koa";
import { basename, extname } from "path";
import { MONTH } from "./constants/constants";
import {
FilruParameters,
Task,
ThumbnailCacheParams,
} from "./types/cacheManager";
import {
BaseImageParameters,
Container,
CropDescription,
ImageParameters,
} from "./types/imageRouter";
import { ImageInfoTool } from "./utils/ImageInfoTool";
import { CacheManager } from "./utils/cache/CacheManager";
import { prepareResolutions } from "./utils/guessResolutions";
import {
checkMaxConcurrent,
encodeFilename,
getImageClasses,
isCorrectExtension,
} from "./utils/utils";
import { fit } from "object-fit-math";
import { hasField } from "@sealcode/ts-predicates";
export type Format = "jpeg" | "webp" | "avif" | "png";
export class KoaResponsiveImageRouter extends Router {
private router: Router;
// Store low resolution thumbnail
private cacheManager: CacheManager;
// Flag to track if the NGINX warning has been displayed
private nginxWarningDisplayed = false;
// Generated thumbnail size in pixels
private defaultThumbnailSize: number;
// id for thumbnail
private currentId = 0;
private staticPath;
private cacheManagerResolutionThreshold;
public formatsForLossy: Format[];
public formatsForLossless: Format[];
/**
* @param {string} static_path - static url
* @param {string} thumbnailSize - thumbnail size in pixels
* @param {number} [cacheManagerResolutionThreshold] - Threshold for
* determining whether images should be stored in memory or on disk in the
* cache manager. It represents the image size in pixels, and images larger
* than this threshold will be stored on disk, while smaller images will be
* stored in memory.
* @param {string} imageStoragePath - cache directory for images
* @param {string} smartCropStoragePath - cache directory for smartcrop results
* @param {number} [maxImagesConcurrent] - number of threads
* @param {number} [diskImageCacheSize] - max allowed size of the cache on disk in
* mega bytes. (default: 50 MB)
* @param {number} [smartCropCacheSize] - max allowed size of the cache on disk in
* mega bytes. (default: 50 MB)
* @param {number} [pruneInterval] - interval to run cache invalidation in
* @param {number} [maxAge] - max allowed age of cached items in seconds.
* milliseconds. (default: 5 minutes)
* @param {number} [hashSeed] - seed for hashing
* @param {number} [thumbnailMaxCacheSize] - max cache size for thumbnails
*/
constructor({
staticPath,
thumbnailSize,
cacheManagerResolutionThreshold,
imageStoragePath,
smartCropStoragePath,
maxImagesConcurrent,
diskImageCacheSize,
smartCropCacheSize,
pruneInterval,
maxAge,
hashSeed,
thumbnailMaxCacheSize,
formatsForLossy = ["avif", "webp", "jpeg"],
formatsForLossless = ["webp", "png"],
}: {
staticPath: string;
thumbnailSize: number;
cacheManagerResolutionThreshold: number;
imageStoragePath: string;
smartCropStoragePath: string;
maxImagesConcurrent?: number;
diskImageCacheSize?: number;
smartCropCacheSize?: number;
pruneInterval?: number;
maxAge?: number;
hashSeed?: string;
thumbnailMaxCacheSize?: number;
formatsForLossy?: Format[];
formatsForLossless?: Format[];
}) {
super();
this.router = new Router();
this.staticPath = staticPath;
this.cacheManagerResolutionThreshold = cacheManagerResolutionThreshold;
this.defaultThumbnailSize = thumbnailSize;
this.formatsForLossy = formatsForLossy;
this.formatsForLossless = formatsForLossless;
const localCachePatameters: FilruParameters = {
storagePath: imageStoragePath,
diskCacheSize: diskImageCacheSize,
pruneInterval: pruneInterval,
maxAge: maxAge,
hashSeed: hashSeed,
};
const smartcropCacheParams: FilruParameters = {
storagePath: smartCropStoragePath,
diskCacheSize: smartCropCacheSize,
pruneInterval: pruneInterval,
maxAge: maxAge,
hashSeed: hashSeed,
};
const thumnailCacheParams: ThumbnailCacheParams = {
maxCacheSize: thumbnailMaxCacheSize,
};
maxImagesConcurrent = checkMaxConcurrent(maxImagesConcurrent);
this.cacheManager = new CacheManager(
thumnailCacheParams,
localCachePatameters,
smartcropCacheParams,
maxImagesConcurrent,
cacheManagerResolutionThreshold
);
this.router.get("/:hash/:filename", async (ctx) => {
// Display NGINX warning if not using a caching proxy
if (!this.nginxWarningDisplayed && !ctx.headers["x-proxied"]) {
console.log(
"Request for an image probably did not go through a caching proxy, use the following NGINX config to fix that:"
);
console.log(this.makeNginxConfig("/run/nginx-cache", 1024));
this.nginxWarningDisplayed = true;
}
const { hash, filename } = ctx.params;
const resolution = parseInt(filename.split(".")[1]);
const fileExtension = extname(filename).split(".").pop();
// Serve image if hash, resolution, and extension are valid
const cropData = ImageInfoTool.getImageData(hash).crop;
if (
!(
ImageInfoTool.getImageData(hash).resolutions.find(
(el: number) => el === resolution
) &&
fileExtension !== undefined &&
isCorrectExtension(fileExtension)
)
) {
ctx.response.status = 404;
return;
}
ctx.set("Cache-Control", `public, max-age=${MONTH}, immutable`);
ctx.set("etag", `"${hash}:${filename}"`);
ctx.status = 200; //otherwise the `.fresh` check won't work, see https://koajs.com/
if (ctx.fresh) {
ctx.status = 304;
return;
}
try {
const thumbnailTask: Task = {
hash: hash,
resolution: resolution,
fileExtension: fileExtension,
cropData: cropData,
};
const imageBuffer =
this.cacheManager.cachedGetProcessedImage(thumbnailTask);
ctx.body = await imageBuffer;
ctx.type = `image/${fileExtension}`;
ctx.status = 200;
} catch (error) {
console.error(error);
ctx.response.status = 404;
}
});
}
async start(): Promise<void> {
await this.cacheManager.start();
}
private makeImageURL({
hash,
width,
extension,
}: {
hash: string;
width: number;
extension: string;
}): string {
const result = `${this.staticPath}/${hash}/${encodeFilename({
width,
originalPath: ImageInfoTool.getImageData(hash).originalPath,
extension,
})}`;
return result;
}
makeNginxConfig(cache_path: string, max_size_mb: number): string {
return `http {
proxy_cache_path ${cache_path} keys_zone=cache:10m levels=1:2 inactive=90d max_size=${max_size_mb}m use_temp_path=off;
server {
# ....
location ${this.staticPath} {
proxy_cache cache;
proxy_cache_lock on;
proxy_cache_valid 200 90d;
proxy_cache_use_stale updating;
proxy_cache_background_update on;
proxy_set_header X-Proxied true;
proxy_pass http://localhost:8080;
}
}
}`;
}
private createImageDefaultParameters(
params: Partial<BaseImageParameters>
): BaseImageParameters {
const result: BaseImageParameters = {
alt: params.alt ? params.alt : "",
lossless: params.lossless ? params.lossless : false,
lazy: params.lazy === undefined ? true : params.lazy,
imgStyle: params.imgStyle || "",
targetRatio: params.targetRatio ? params.targetRatio : 16 / 9,
ratioDiffThreshold: params.ratioDiffThreshold
? params.ratioDiffThreshold
: 0.2,
thumbnailSize: params.thumbnailSize
? params.thumbnailSize
: this.defaultThumbnailSize,
crop: false,
style: "",
};
return result;
}
/**
* Generates an <img> tag with responsive attributes based on the provided parameters.
*
* This function takes various parameters to create an HTML <img> tag with responsive attributes,
* allowing for flexible customization of image display and behavior.
*
* @param {BaseImageParameters} params - An object containing base image parameters.
* @param {number[]} [params.resolutions] - An array of resolutions for responsive images.
* @param {string} params.sizesAttr - The "sizes" attribute for the <img> tag, specifying responsive behavior based on available space.
* @param {string} params.path - The path to the original image to be processed and delivered by the function.
* @param {string} params.alt - The "alt" attribute for the <img> tag, describing the image content.
* @param {boolean} [params.lossless=false] - A boolean indicating whether to use lossless compression for images (default: false).
* @param {boolean} [params.lazy=true] - A boolean indicating whether lazy loading of images should be enabled (default: true).
* @param {string} [params.imgStyle] - CSS styles to be applied to the <img> tag.
* @param {number} [params.targetRatio=16/9] - The target aspect ratio for cropping images (default: 16/9).
* @param {number} [params.ratioDiffThreshold=0.2] - The threshold for acceptable aspect ratio differences (default: 0.2).
* @param {number} [params.thumbnailSize] - Custom thumbnail size.
* @param {SmartCropOptions | DirectCropOptions} [params.crop] - Options for smart cropping or direct cropping of images.
*
* @return {Promise<string>} - A string representing the HTML <img> tag with appropriate attributes and CSS classes.
*/
async image(path: string, params: ImageParameters): Promise<string> {
const container: Container | null = hasField("container", params)
? params.container
: null;
if (!path) {
return "";
}
const metadata = await ImageInfoTool.getMetadata(path);
const crop = params.crop || false;
const imageParams = this.createImageDefaultParameters(params);
const resolutions = prepareResolutions({
...params,
original_image_size: {
width: metadata.width as number,
height: metadata.height as number,
},
thumbnailSize: params.thumbnailSize || this.defaultThumbnailSize,
});
const hash = this.getHash(
path,
resolutions,
imageParams.targetRatio,
imageParams.ratioDiffThreshold,
container,
crop
);
ImageInfoTool.initImageData(hash);
ImageInfoTool.updateProperty(hash, "resolutions", resolutions);
ImageInfoTool.updateProperty(hash, "lossless", imageParams.lossless);
ImageInfoTool.updateProperty(hash, "originalPath", path);
ImageInfoTool.updateProperty(
hash,
"targetRatio",
imageParams.targetRatio
);
ImageInfoTool.updateProperty(
hash,
"ratioDiffThreshold",
imageParams.ratioDiffThreshold
);
if (params.crop) {
ImageInfoTool.updateProperty(hash, "crop", params.crop);
}
ImageInfoTool.updateProperty(
hash,
"thumbnailSize",
imageParams.thumbnailSize
);
const imgDimensions = {
width: metadata.width || 100,
height: metadata.height || 100,
};
const extensions = imageParams.lossless
? this.formatsForLossless
: this.formatsForLossy;
let imageWidth = imgDimensions.width;
let imageHeight = imgDimensions.height;
let objectWidth: number = imgDimensions.width;
if (container) {
ImageInfoTool.updateProperty(hash, "container", container);
if (container.height > 0 && container.width > 0) {
const objectSize = this.calculateImageSizeForContainer(
imgDimensions.width,
imgDimensions.height,
container.width,
container.height,
container.objectFit || "contain"
);
objectWidth = objectSize.width;
imageHeight = objectSize.height;
imageWidth = container.width;
imageParams.imgStyle =
(imageParams.imgStyle || "") +
`object-fit: ${
container.objectFit || "contain"
}; width: 100%; height: 100%; backdrop-filter: blur(5px)`;
} else {
throw new Error("Invalid container dimensions");
}
}
let html = "";
let background_size = "100% 100%";
if (container) {
const fitted_image_size = fit(
container,
imgDimensions,
container.objectFit || "contain"
);
background_size = `${
(fitted_image_size.width / container.width) * 100
}% ${(fitted_image_size.height / container.height) * 100}%`;
}
const styles: string[] = [
`display: inline-flex`, // to prevent weird padding at the bottom of the image
`background-size: ${background_size}`,
`background-position: 50%`,
`background-repeat: no-repeat`,
`width: ${container?.width ? container.width + "px" : "100%"}`,
];
html = "<picture ";
html += ` style="`;
if (params.thumbnailSize !== 0) {
const thumbnailExtension = "jpeg";
const thumbnailTask: Task = {
hash: hash,
resolution: imageParams.thumbnailSize,
fileExtension: thumbnailExtension,
cropData: crop,
};
const lowResCacheBase64 =
this.cacheManager.isInCache(thumbnailTask);
const has_cache =
lowResCacheBase64 &&
ImageInfoTool.getImageData(hash).thumbnailSize <=
this.cacheManagerResolutionThreshold;
const thumbnailURL = has_cache
? `data:image/*;base64,${lowResCacheBase64}`
: this.makeImageURL({
hash,
width: ImageInfoTool.getImageData(hash).thumbnailSize,
extension: thumbnailExtension,
});
styles.push(`background-image: url(${thumbnailURL})`);
}
html += `${styles.join(";")} ${params.style || ""}"`;
let sizes = "";
if ("sizesAttr" in params && params.sizesAttr) {
sizes = params.sizesAttr;
} else if ("container" in params && params.container) {
const fitted_image_size = fit(
params.container,
metadata as { width: number; height: number },
params.container.objectFit || "contain"
);
sizes += `${
params.container.width
? Math.min(
fitted_image_size.width,
metadata.width as number
)
: objectWidth
}px`;
}
html += ">";
html += this.generateResponsiveImageSources(
hash,
extensions,
resolutions,
sizes
);
html += this.generateMainImageTag(
hash,
{ width: imageWidth, height: imageHeight },
imageParams.lazy,
imageParams.imgStyle ||
"width: 100%; height: 100%; backdrop-filter: blur(5px)",
imageParams.alt,
resolutions
);
html += "</picture> ";
return html;
}
async singleImage(
path: string,
imageSize: number,
fileExtension: string,
lossless: boolean
): Promise<string> {
if (!path || !imageSize || !fileExtension) {
return "";
}
const resolutions = [imageSize];
const hash = this.getHash(path, resolutions, 1, 1, null, false);
ImageInfoTool.initImageData(hash);
ImageInfoTool.updateProperty(hash, "resolutions", resolutions);
ImageInfoTool.updateProperty(hash, "lossless", lossless);
ImageInfoTool.updateProperty(hash, "originalPath", path);
const imgURL = this.makeImageURL({
hash,
width: resolutions[0],
extension: fileExtension,
});
return imgURL;
}
private generateResponsiveImageSources(
hash: string,
extensions: string[],
resolutions: number[],
sizes: string
): string {
const sourceTags = extensions.map((extension) => {
const srcset = resolutions
.map((resolution) => {
const imgURL = this.makeImageURL({
hash,
width: resolution,
extension,
});
return `${imgURL} ${Math.round(resolution)}w`;
})
.join(", ");
return `<source srcset="${srcset}" sizes="${sizes}" type="image/${extension}" />`;
});
return sourceTags.join("\n");
}
private generateMainImageTag(
hash: string,
imgDimensions: { width: number; height: number },
lazy: boolean,
imgStyle: string | undefined,
alt: string | undefined,
resolutions: number[]
): string {
const midResolutionIndex = Math.max(
Math.floor(resolutions.length / 2) - 1,
0
);
const midResolution = resolutions[midResolutionIndex];
const imgURL = this.makeImageURL({
hash,
width: midResolution,
extension: "jpeg",
});
const lazyLoading = lazy ? `loading="lazy"` : "";
imgStyle = imgStyle ? `style="${imgStyle}"` : "";
const altText = typeof alt == "string" ? `alt="${alt}"` : "";
return `<img class="${getImageClasses({
width: imgDimensions.width,
height: imgDimensions.height,
targetRatio: ImageInfoTool.getImageData(hash).targetRatio,
ratioDiffThreshold:
ImageInfoTool.getImageData(hash).ratioDiffThreshold,
- }).join(" ")}" ${lazyLoading} width="${imgDimensions.width}" height="${
+ }).join(" ")}" ${lazyLoading} width="${
+ imgDimensions.width
+ }" height="${Math.round(
imgDimensions.height
- }" ${imgStyle} src="${imgURL}" ${altText} />`;
+ )}" ${imgStyle} src="${imgURL}" ${altText} />`;
}
public calculateImageSizeForContainer(
imageWidth: number,
imageHeight: number,
containerWidth: number,
containerHeight: number,
objectFit: string = "contain"
): { width: number; height: number } {
let targetWidth: number, targetHeight: number;
if (containerWidth <= 0 || containerHeight <= 0) {
targetWidth = 0;
targetHeight = 0;
} else {
const containerAspect = containerWidth / containerHeight;
const imageAspect = imageWidth / imageHeight;
if (containerAspect === imageAspect) {
targetWidth = containerWidth;
targetHeight = containerHeight;
}
if (objectFit === "cover") {
if (containerAspect > imageAspect) {
targetWidth = containerWidth;
targetHeight = containerWidth / imageAspect;
} else {
targetHeight = containerHeight;
targetWidth = containerHeight * imageAspect;
}
} else if (objectFit === "contain") {
if (containerAspect < imageAspect) {
targetWidth = containerWidth;
targetHeight = containerWidth / imageAspect;
} else {
targetHeight = containerHeight;
targetWidth = containerHeight * imageAspect;
}
} else {
targetWidth = containerWidth;
targetHeight = containerHeight;
}
}
return {
width: targetWidth,
height: targetHeight,
};
}
private getHash(
original_file_path: string,
resolutions: number[],
target_ratio: number,
ratio_diff_threshold: number,
container: Container | null,
crop: CropDescription
) {
const containerString = container ? JSON.stringify(container) : "";
const cropString = crop ? JSON.stringify(crop) : "";
return crypto
.createHash("SHA1")
.update(
`
${basename(original_file_path)}
${
"" /* (await stat(original_file_path)).mtime.getTime() // -- commented out. seems like it's not worth checking the mtime each time. Let's assume that if the file changes, so does its filename. */
}
${JSON.stringify(resolutions)}
${JSON.stringify(target_ratio)}
${JSON.stringify(ratio_diff_threshold)}
${containerString}
${cropString}
`
)
.digest("hex");
}
getRoutes(): Middleware {
return this.router.routes();
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Oct 11, 07:14 (19 h, 13 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
983954
Default Alt Text
(18 KB)
Attached To
Mode
rRIMAGEROUTER koa-responsive-image-router
Attached
Detach File
Event Timeline
Log In to Comment