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).
Package structure
Section titled “Package structure”DirectoryGranit.Imaging/ Image processing abstractions, fluent pipeline API
- Granit.Imaging.MagickNet Magick.NET implementation (JPEG/PNG/WebP/AVIF/GIF/BMP/TIFF)
| Package | Role | Depends on |
|---|---|---|
Granit.Imaging | IImageProcessor, IImagePipeline fluent API | Granit |
Granit.Imaging.MagickNet | MagickNetImageProcessor (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.
Configuration
Section titled “Configuration”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 } }}| Option | Type | Default | Purpose |
|---|---|---|---|
MaxMemoryBytes | long | 256 MB | ImageMagick pixel-cache memory ceiling |
MaxWidthPixels | int | 16384 | Maximum decoded image width |
MaxHeightPixels | int | 16384 | Maximum decoded image height |
MaxListLength | int | 32 | Maximum frames (GIF animation) |
MaxInputBytes | long | 50 MB | Pre-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
Section titled “IImageProcessor”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:
| Member | Kind | Use for |
|---|---|---|
LoadAsync(Stream, CancellationToken) | async | Decoding from a stream (HTTP upload, blob download) — non-seekable streams are buffered asynchronously before decode |
Load(ReadOnlyMemory<byte>) | sync | Decoding from bytes already in memory |
Identify(ReadOnlyMemory<byte>) | sync | Reading 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: header-only inspection
Section titled “Identify: header-only inspection”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 decodeif (info.Size.Width * info.Size.Height > MaxPixels) return Results.BadRequest("Image too large.");Pipeline operations
Section titled “Pipeline operations”| Method | Description |
|---|---|
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 |
Resize modes
Section titled “Resize modes”| Mode | Behavior |
|---|---|
Max | Fit within bounds, preserving aspect ratio (may be smaller than target) |
Pad | Fit within bounds with transparent padding to exact dimensions |
Crop | Fill exact dimensions by resizing and center-cropping overflow |
Stretch | Stretch to exact dimensions (distorts aspect ratio) |
Min | Resize to minimum bounds covering target dimensions entirely |
Convenience methods
Section titled “Convenience methods”await pipeline.SaveAsWebPAsync(cancellationToken); // ConvertTo(WebP) + ToResultAsyncawait pipeline.SaveAsAvifAsync(cancellationToken); // ConvertTo(AVIF) + ToResultAsyncawait pipeline.SaveAsJpegAsync(cancellationToken); // ConvertTo(JPEG) + ToResultAsyncawait pipeline.SaveAsPngAsync(cancellationToken); // ConvertTo(PNG) + ToResultAsyncSupported formats
Section titled “Supported formats”| Format | Read | Write | Notes |
|---|---|---|---|
| JPEG | Yes | Yes | Lossy, no transparency |
| PNG | Yes | Yes | Lossless, transparency |
| WebP | Yes | Yes | Modern lossy/lossless, smaller than JPEG |
| AVIF | Yes | Yes | Next-gen, best compression ratio |
| GIF | Yes | Yes | 256 colors, animation support |
| BMP | Yes | Yes | Uncompressed bitmap |
| TIFF | Yes | Yes | Lossless, medical imaging |
GDPR: metadata stripping
Section titled “GDPR: metadata stripping”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);Combining with blob storage
Section titled “Combining with blob storage”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.
Security
Section titled “Security”Granit.Imaging.MagickNet applies three layers of defense against malicious image uploads:
Format allowlisting
Section titled “Format allowlisting”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.
Resource limits
Section titled “Resource limits”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
ImagingMagickNetOptionsas the host boots. - Because it runs on the host lifetime, the process-global limits are not applied
outside a host — a bare
MagickNetImageProcessorbuilt 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.
Input validation
Section titled “Input validation”- Resize / Crop: negative or zero dimensions are rejected with
ArgumentOutOfRangeException(prevents integer overflow fromint→uintcast) - Compress: quality is validated in the 0–100 range
- Watermark:
opacityis validated in the[0, 1]range (ArgumentOutOfRangeExceptionoutside it) MaxInputBytes: enforced per request before decode, independently of the process-global limits- Non-seekable streams: automatically buffered (asynchronously, in
LoadAsync) before validation
Observability
Section titled “Observability”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.
| Span | Emitted by | Tags |
|---|---|---|
imaging.load | LoadAsync / Load | imaging.source_format, imaging.width, imaging.height, imaging.input_bytes |
imaging.identify | Identify | imaging.source_format, imaging.width, imaging.height, imaging.input_bytes |
imaging.encode | pipeline terminal op | imaging.output_format, imaging.width, imaging.height |
| Metric | Type | Tags |
|---|---|---|
granit.imaging.image.processed | counter | tenant_id, output_format |
granit.imaging.image.processing_duration | histogram (s) | tenant_id, output_format |
Public API summary
Section titled “Public API summary”| Category | Key types | Package |
|---|---|---|
| Module | GranitImagingModule, GranitImagingMagickNetModule | — |
| Processor | IImageProcessor, IImagePipeline, ImageResult, ImageInfo | Granit.Imaging |
| Formats | ImageFormat, ImageSize | Granit.Imaging |
| Modes | ResizeMode, CropRectangle, WatermarkPosition | Granit.Imaging |
| Exceptions | UnsupportedImageFormatException | Granit.Imaging |
| Options | ImagingMagickNetOptions | Granit.Imaging.MagickNet |
| Extensions | AddGranitImagingMagickNet() | Granit.Imaging.MagickNet |
See also
Section titled “See also”- ADR-013: Magick.NET — Why Magick.NET was chosen for image processing
- Blob Storage module — Multi-provider storage, presigned URLs, validation
- Privacy module — GDPR compliance patterns