Skip to content

Imaging — Resize, Convert & Optimize Images

Granit.Imaging provides a fluent image processing pipeline for transformations such as resize, crop, compress, format conversion, and watermarking. Call StripMetadata() in the pipeline to remove GPS coordinates and camera details from uploaded photos (GDPR). The Magick.NET implementation supports JPEG, PNG, WebP, AVIF, GIF, BMP, and TIFF with built-in security hardening (resource limits, format allowlisting, input size validation).

  • DirectoryGranit.Imaging/ Image processing abstractions, fluent pipeline API
    • Granit.Imaging.MagickNet Magick.NET implementation (JPEG/PNG/WebP/AVIF/GIF/BMP/TIFF)
PackageRoleDepends on
Granit.ImagingIImageProcessor, IImagePipeline fluent APIGranit
Granit.Imaging.MagickNetMagickNetImageProcessor (singleton)Granit.Imaging
[DependsOn(typeof(GranitImagingMagickNetModule))]
public class AppModule : GranitModule { }
builder.AddGranitImagingMagickNet();

MagickNetImageProcessor is registered as a singleton — Magick.NET is thread-safe and initializes its native codec once per process. Resource limits are configurable — see Configuration below.

ImagingMagickNetOptions binds from the Imaging:MagickNet configuration section and is validated at startup (ValidateOnStart) — a value outside its allowed range fails the host boot rather than surfacing at the first upload:

{
"Imaging": {
"MagickNet": {
"MaxMemoryBytes": 268435456,
"MaxWidthPixels": 16384,
"MaxHeightPixels": 16384,
"MaxListLength": 32,
"MaxInputBytes": 52428800
}
}
}
OptionTypeDefaultPurpose
MaxMemoryByteslong256 MBImageMagick pixel-cache memory ceiling
MaxWidthPixelsint16384Maximum decoded image width
MaxHeightPixelsint16384Maximum decoded image height
MaxListLengthint32Maximum frames (GIF animation)
MaxInputByteslong50 MBPre-decode input size check (0 disables it)

The options callback on AddGranitImagingMagickNet(...) runs after configuration binding, so code wins over appsettings.json — use it for a hard floor a deployment cannot weaken:

builder.AddGranitImagingMagickNet(options =>
{
options.MaxInputBytes = 25 * 1024 * 1024; // overrides any bound value
});

IImageProcessor exposes three entry points. Both Load overloads return an IImagePipeline that holds native resources and must be disposed after use; Identify is a cheap header-only probe that allocates no pipeline:

MemberKindUse for
LoadAsync(Stream, CancellationToken)asyncDecoding from a stream (HTTP upload, blob download) — non-seekable streams are buffered asynchronously before decode
Load(ReadOnlyMemory<byte>)syncDecoding from bytes already in memory
Identify(ReadOnlyMemory<byte>)syncReading dimensions and format without decoding pixels
public class PatientPhotoProcessor(IImageProcessor imageProcessor)
{
public async Task<ImageResult> CreateThumbnailAsync(
Stream sourceImage,
CancellationToken cancellationToken)
{
await using IImagePipeline pipeline = await imageProcessor
.LoadAsync(sourceImage, cancellationToken)
.ConfigureAwait(false);
return await pipeline
.Resize(200, 200, ResizeMode.Crop)
.StripMetadata()
.Compress(quality: 80)
.ConvertTo(ImageFormat.WebP)
.ToResultAsync(cancellationToken);
}
}

Identify reads only the image header and returns an ImageInfo value — (ImageSize Size, ImageFormat Format) — without decoding a single pixel. That makes it both fast and a pixel-bomb guard: a crafted file that would inflate to gigabytes is rejected at the header stage. It runs the same magic-byte format allowlist as Load, and throws UnsupportedImageFormatException (namespace Granit.Imaging.Exceptions) for an unknown or unreadable format:

ImageInfo info = imageProcessor.Identify(uploadedBytes); // no pipeline, no decode
if (info.Size.Width * info.Size.Height > MaxPixels)
return Results.BadRequest("Image too large.");
MethodDescription
Resize(width, height, mode)Resize with configurable strategy
Crop(rectangle)Crop to rectangular region
Compress(quality)Set output quality (0-100)
ConvertTo(format)Change output format
Watermark(data, position, opacity)Composite watermark overlay
StripMetadata()Remove EXIF, IPTC, XMP metadata (GDPR)
ToResultAsync()Terminal: encode and return ImageResult
SaveToStreamAsync(stream)Terminal: encode and write to stream
ModeBehavior
MaxFit within bounds, preserving aspect ratio (may be smaller than target)
PadFit within bounds with transparent padding to exact dimensions
CropFill exact dimensions by resizing and center-cropping overflow
StretchStretch to exact dimensions (distorts aspect ratio)
MinResize to minimum bounds covering target dimensions entirely
await pipeline.SaveAsWebPAsync(cancellationToken); // ConvertTo(WebP) + ToResultAsync
await pipeline.SaveAsAvifAsync(cancellationToken); // ConvertTo(AVIF) + ToResultAsync
await pipeline.SaveAsJpegAsync(cancellationToken); // ConvertTo(JPEG) + ToResultAsync
await pipeline.SaveAsPngAsync(cancellationToken); // ConvertTo(PNG) + ToResultAsync
FormatReadWriteNotes
JPEGYesYesLossy, no transparency
PNGYesYesLossless, transparency
WebPYesYesModern lossy/lossless, smaller than JPEG
AVIFYesYesNext-gen, best compression ratio
GIFYesYes256 colors, animation support
BMPYesYesUncompressed bitmap
TIFFYesYesLossless, medical imaging

StripMetadata() removes all EXIF, IPTC, and XMP metadata from images. This is critical for GDPR compliance: uploaded photos often contain GPS coordinates, camera serial numbers, timestamps, and other personally identifiable information.

await using IImagePipeline pipeline = await imageProcessor
.LoadAsync(uploadedPhoto, cancellationToken)
.ConfigureAwait(false);
ImageResult sanitized = await pipeline
.StripMetadata()
.SaveAsWebPAsync(cancellationToken);

A typical pattern: subscribe to BlobValidatedEvent events to post-process images asynchronously after upload validation. The Wolverine handler downloads the validated blob, applies the image pipeline, and re-uploads the result:

public static class BlobValidatedEventHandler
{
public static async Task HandleAsync(
BlobValidatedEvent @event,
IBlobStorage blobStorage,
IImageProcessor imageProcessor,
CancellationToken cancellationToken)
{
if (@event.ContainerName != "patient-photos")
return;
PresignedDownloadUrl download = await blobStorage
.CreateDownloadUrlAsync("patient-photos", @event.BlobId,
cancellationToken: cancellationToken)
.ConfigureAwait(false);
// fetch image from download.Url, process with imageProcessor,
// re-upload thumbnail to "patient-photos-thumbs" container
}
}

This keeps the upload path fast (202 Accepted) while image processing runs in the background. See Blob Storage domain events for the full event list.

Granit.Imaging.MagickNet applies three layers of defense against malicious image uploads:

Before passing any data to the native ImageMagick decoder, LoadAsync, Load, and Identify validate magic bytes against a safe raster format allowlist (JPEG, PNG, GIF, BMP, TIFF, WebP, AVIF). Dangerous formats that ImageMagick can parse — SVG (SSRF via xlink:href), MSL (file I/O), MVG, PDF, EPS — are rejected before native code executes.

The configured limits prevent decompression-bomb attacks (a 42 KB crafted JPEG can inflate to 4+ GB). ImageMagick’s ResourceLimits are process-global native state, so a hosted service (MagickNetResourceLimitsInitializer) applies them once at host startup:

  • The limits are a property of the native ImageMagick runtime, not of a DI scope. Within a process the last writer wins; the initializer sets them from ImagingMagickNetOptions as the host boots.
  • Because it runs on the host lifetime, the process-global limits are not applied outside a host — a bare MagickNetImageProcessor built from a service provider in a unit test does not pick them up. The per-request guards (format allowlist, MaxInputBytes, dimension checks) do not depend on it and always run.
  • Resize / Crop: negative or zero dimensions are rejected with ArgumentOutOfRangeException (prevents integer overflow from intuint cast)
  • Compress: quality is validated in the 0–100 range
  • Watermark: opacity is validated in the [0, 1] range (ArgumentOutOfRangeException outside it)
  • MaxInputBytes: enforced per request before decode, independently of the process-global limits
  • Non-seekable streams: automatically buffered (asynchronously, in LoadAsync) before validation

Granit.Imaging.MagickNet emits OpenTelemetry spans and metrics under the Granit.Imaging.MagickNet / Granit.Imaging sources — all tenant-tagged with the real ambient tenant (ICurrentTenant, read at the terminal operation), coalesced to "global" when no tenant is in scope.

SpanEmitted byTags
imaging.loadLoadAsync / Loadimaging.source_format, imaging.width, imaging.height, imaging.input_bytes
imaging.identifyIdentifyimaging.source_format, imaging.width, imaging.height, imaging.input_bytes
imaging.encodepipeline terminal opimaging.output_format, imaging.width, imaging.height
MetricTypeTags
granit.imaging.image.processedcountertenant_id, output_format
granit.imaging.image.processing_durationhistogram (s)tenant_id, output_format
CategoryKey typesPackage
ModuleGranitImagingModule, GranitImagingMagickNetModule
ProcessorIImageProcessor, IImagePipeline, ImageResult, ImageInfoGranit.Imaging
FormatsImageFormat, ImageSizeGranit.Imaging
ModesResizeMode, CropRectangle, WatermarkPositionGranit.Imaging
ExceptionsUnsupportedImageFormatExceptionGranit.Imaging
OptionsImagingMagickNetOptionsGranit.Imaging.MagickNet
ExtensionsAddGranitImagingMagickNet()Granit.Imaging.MagickNet