@nestbolt/medialibrary
Image Conversions
Automatically generate thumbnails and image variants using Sharp -- resize, crop, format, quality, blur, sharpen, rotate, and more.
Image conversions let you automatically generate thumbnails, previews, and other image variants whenever a media file is uploaded. Conversions are defined on your entity class using the @RegisterMediaConversions decorator and are powered by Sharp.
Prerequisites
Sharp must be installed as a peer dependency:
npm install sharpIf Sharp is not installed and a conversion is triggered, the library throws a descriptive error at runtime.
Conversions are only performed on image files (MIME types starting with image/, excluding image/svg+xml). Non-image uploads are stored normally but skip the conversion step.
Defining Conversions
Use the @RegisterMediaConversions decorator on your entity class. The decorator receives a callback with an addConversion function that returns a ConversionBuilder:
import { Entity, PrimaryGeneratedColumn, BaseEntity } from "typeorm";
import {
HasMedia,
HasMediaMixin,
RegisterMediaConversions,
} from "@nestbolt/medialibrary";
@Entity("posts")
@HasMedia()
@RegisterMediaConversions((addConversion) => {
addConversion("thumbnail")
.resize(150, 150, { fit: "cover" })
.format("webp")
.quality(80);
addConversion("preview")
.resize(800)
.format("webp")
.quality(85)
.sharpen();
})
export class Post extends HasMediaMixin(BaseEntity) {
@PrimaryGeneratedColumn("uuid")
id!: string;
}When an image is uploaded to this entity, both "thumbnail" and "preview" variants are automatically generated and stored alongside the original.
ConversionBuilder Methods
resize(width?, height?, options?)
Resizes the image. You can specify width, height, or both. The options object is passed directly to Sharp's resize():
// Resize to 300px wide, auto height
addConversion("medium").resize(300);
// Resize to exactly 150x150, cropping to cover
addConversion("thumb").resize(150, 150, { fit: "cover" });
// Resize to fit within 800x600, maintaining aspect ratio
addConversion("preview").resize(800, 600, { fit: "inside" });
// Resize with specific options
addConversion("custom").resize(400, 400, {
fit: "cover",
position: "top",
withoutEnlargement: true,
});Available fit values:
| Fit | Description |
|---|---|
"cover" | Crop to cover both dimensions (default for Sharp) |
"contain" | Fit within both dimensions, adding letterboxing if needed |
"fill" | Stretch to fill both dimensions exactly |
"inside" | Fit inside the dimensions without cropping |
"outside" | Fit outside the dimensions, may extend beyond |
crop(width, height, left?, top?)
Extracts a region from the image. The left and top parameters default to 0:
// Crop a 400x300 region from the top-left corner
addConversion("cropped").crop(400, 300);
// Crop a 200x200 region starting at position (100, 50)
addConversion("detail").crop(200, 200, 100, 50);Internally, this uses Sharp's extract() method.
format(format, options?)
Converts the output to a specific image format:
addConversion("webp-version").format("webp");
addConversion("jpeg-version").format("jpeg", { progressive: true });
addConversion("avif-version").format("avif", { quality: 50 });
addConversion("png-version").format("png", { compressionLevel: 9 });Supported formats:
| Format | Notes |
|---|---|
"jpeg" | Lossy. Supports quality, progressive, chromaSubsampling. |
"png" | Lossless. Supports compressionLevel, palette. |
"webp" | Lossy or lossless. Supports quality, lossless, nearLossless. |
"avif" | Lossy. Supports quality. Requires Sharp 0.33+. |
"gif" | Animated GIF support. |
"tiff" | Supports quality, compression. |
When format() is used, the output file's extension is changed to match the new format. For example, uploading photo.jpg with .format("webp") produces a conversion file named thumbnail-photo.webp.
quality(q)
Sets the output quality for lossy formats (JPEG, WebP, AVIF). Value range is 0-100:
addConversion("compressed").format("webp").quality(60);
addConversion("high-quality").format("jpeg").quality(95);If quality() is used without format(), the original image format is inferred and the quality is applied to that format.
blur(sigma?)
Applies a Gaussian blur. When called without arguments, Sharp uses a fast, mild blur. The sigma parameter controls the blur intensity (0.3-1000):
addConversion("blurred").blur();
addConversion("heavy-blur").blur(10);
addConversion("subtle-blur").blur(0.5);sharpen(options?)
Sharpens the image. When called without arguments, a mild sharpen is applied:
addConversion("sharp").sharpen();
addConversion("custom-sharp").sharpen({ sigma: 1, m1: 1.5, m2: 0.7 });Sharpen options:
| Option | Description |
|---|---|
sigma | The sigma of the Gaussian mask (0.01-10000) |
m1 | The level of flat area sharpening (default: 1.0) |
m2 | The level of jagged area sharpening (default: 2.0) |
rotate(angle?)
Rotates the image by the given angle in degrees. When called without arguments, Sharp auto-rotates based on EXIF orientation data:
addConversion("rotated").rotate(90);
addConversion("auto-rotated").rotate();
addConversion("upside-down").rotate(180);Positive angles rotate clockwise. Negative angles rotate counter-clockwise.
flip()
Flips the image vertically (top to bottom):
addConversion("flipped").flip();flop()
Flips the image horizontally (left to right, mirror):
addConversion("mirrored").flop();greyscale()
Converts the image to greyscale (single-channel luminance):
addConversion("grey").greyscale();negate()
Inverts all pixel colors, producing a negative:
addConversion("negative").negate();normalize()
Enhances contrast by stretching the luminance range to cover the full 0-255 range:
addConversion("normalized").normalize();withSharpOperation(operation, ...args)
Calls any Sharp pipeline method by name. This is an escape hatch for operations not covered by the built-in methods:
// Apply a tint
addConversion("tinted").withSharpOperation("tint", { r: 255, g: 200, b: 200 });
// Apply gamma correction
addConversion("gamma").withSharpOperation("gamma", 2.2);
// Apply median filter
addConversion("median").withSharpOperation("median", 3);
// Extend/pad the image
addConversion("padded").withSharpOperation("extend", {
top: 10,
bottom: 10,
left: 10,
right: 10,
background: { r: 255, g: 255, b: 255, alpha: 1 },
});Any method available on a Sharp instance can be called this way. See the Sharp API documentation for the full list.
performOnCollections(...collections)
Restricts the conversion to only apply when files are uploaded to specific collections. By default, a conversion applies to all collections:
addConversion("thumbnail")
.resize(150, 150, { fit: "cover" })
.format("webp")
.performOnCollections("images", "avatar");In this example, "thumbnail" is only generated when files are uploaded to the "images" or "avatar" collections. Uploads to other collections (e.g., "documents") skip this conversion.
keepOriginalImageFormat()
Keeps the original image format instead of converting to a different format. This is useful when you want to resize without changing the file type:
addConversion("small")
.resize(400)
.keepOriginalImageFormat();If a JPEG is uploaded, the conversion output is also JPEG. If a PNG is uploaded, the output is PNG.
queued() / nonQueued()
Marks the conversion as queued or non-queued. This is a metadata flag that can be used in custom implementations for background processing:
addConversion("heavy-processing")
.resize(2000)
.format("avif")
.quality(50)
.queued();Chaining Multiple Operations
Conversion operations can be chained to apply multiple transformations in sequence. They are applied in the order they are defined:
addConversion("watermark-ready")
.resize(800, 600, { fit: "inside" })
.sharpen({ sigma: 0.5 })
.format("webp")
.quality(85)
.performOnCollections("gallery");addConversion("retro")
.resize(600)
.greyscale()
.blur(0.5)
.normalize()
.format("jpeg")
.quality(70);Retrieving Converted Images
After upload, retrieve conversion URLs using getUrl() with the conversion name:
const media = await mediaService.getFirstMedia("Post", postId, "images");
// Original image URL
const originalUrl = mediaService.getUrl(media);
// Thumbnail conversion URL
const thumbUrl = mediaService.getUrl(media, "thumbnail");
// Preview conversion URL
const previewUrl = mediaService.getUrl(media, "preview");Checking Conversion Existence
The MediaEntity tracks which conversions have been generated:
if (media.hasGeneratedConversion("thumbnail")) {
const thumbUrl = mediaService.getUrl(media, "thumbnail");
}
// Check the full map
console.log(media.generatedConversions);
// { thumbnail: true, preview: true }Regenerating Conversions
If you add new conversions to an entity class or want to reprocess existing media, use regenerateConversions():
// Regenerate all conversions for a media item
await mediaService.regenerateConversions(media);
// Regenerate only specific conversions
await mediaService.regenerateConversions(media, ["thumbnail"]);This re-reads the original file from storage, applies the conversion pipeline, and writes the new output. The media.generatedConversions map is updated accordingly.
Batch Regeneration
To regenerate conversions for all media of a certain type:
const allPostMedia = await mediaService.getMedia("Post", postId, "images");
for (const media of allPostMedia) {
await mediaService.regenerateConversions(media);
}Conversion Storage
Conversions are stored in a conversions/ subdirectory relative to the original file. Using the default path generator:
<media-uuid>/
photo.jpg (original)
conversions/
thumbnail-photo.webp (thumbnail conversion)
preview-photo.webp (preview conversion)The conversion file name is <conversion-name>-<original-file-name>, with the extension changed if a format conversion was applied.
If storingConversionsOnDisk() was used during upload (or storeConversionsOnDisk() on the collection), conversions are stored on a different disk than the original.
Complete Example
import { Entity, PrimaryGeneratedColumn, Column, BaseEntity } from "typeorm";
import {
HasMedia,
HasMediaMixin,
RegisterMediaCollections,
RegisterMediaConversions,
} from "@nestbolt/medialibrary";
@Entity("articles")
@HasMedia()
@RegisterMediaCollections((addCollection) => {
addCollection("hero")
.acceptsMimeTypes(["image/jpeg", "image/png", "image/webp"])
.singleFile();
addCollection("gallery")
.acceptsMimeTypes(["image/jpeg", "image/png", "image/webp"])
.onlyKeepLatest(50)
.useDisk("s3");
})
@RegisterMediaConversions((addConversion) => {
// Small square thumbnail for listings
addConversion("thumb")
.resize(150, 150, { fit: "cover" })
.format("webp")
.quality(75)
.performOnCollections("hero", "gallery");
// Medium preview for article cards
addConversion("card")
.resize(400, 300, { fit: "cover" })
.format("webp")
.quality(80)
.performOnCollections("hero", "gallery");
// Large optimized version for the article page
addConversion("full")
.resize(1920)
.format("webp")
.quality(90)
.sharpen()
.performOnCollections("hero");
// Greyscale version for aesthetic purposes
addConversion("bw")
.resize(800)
.greyscale()
.format("jpeg")
.quality(85)
.performOnCollections("gallery");
})
export class Article extends HasMediaMixin(BaseEntity) {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column()
title!: string;
@Column({ type: "text" })
body!: string;
}