If you have ever wired up an ASP.NET Core photo upload and watched a 12-megapixel phone JPEG land in blob storage at full size, sideways, with GPS still in the EXIF, you already know the feeling. The endpoint works. The CDN bill and the broken avatars arrive later.
I also know the other path by heart: pull in SkiaSharp, remember which native asset package Linux needs, apply EXIF orientation yourself, pick a fit mode that does not letterbox by accident, binary-search JPEG quality until the file is under 100 KB, and hope nobody posts a decompression bomb. It works. It is also easy to get slightly wrong on the second feature.
So I built PicReady: a small .NET 8/9 library that wraps SkiaSharp for the everyday web-app jobs — resize, crop, avatar, WebP, strip EXIF, hit a max byte size — behind a short fluent API. PicReady.Lite is the free MIT subset for a plain resize. This post is the problem, the API, real numbers from one stopwatch run, and when you should not use it.
Why raw uploads hurt
Phone cameras send large JPEGs. Many are tagged with Orientation 6 or 8, so the pixels are sideways until something rotates them. If you store the file as-is, every viewer has to guess. If you resize without applying that tag first, the crop is wrong and the avatar looks like it fell over.
There is also the safety problem. A tiny PNG whose header claims a huge canvas can chew through memory before your code ever draws a pixel. On a public upload endpoint that is not a theoretical concern.
Why hand-rolled SkiaSharp is fiddly
SkiaSharp is excellent and MIT licensed. It is also a drawing toolkit, not an upload pipeline. Fit modes, EXIF orient-then-strip, "encode until under N bytes," circle avatars, responsive size sets, and an IFormFile helper are all things you end up writing yourself. Magick.NET covers more ground, but it brings native ImageMagick dependencies. System.Drawing is the wrong tool for modern ASP.NET Core on Linux.
ImageSharp covers general imaging well. For closed-source, for-profit teams over the community threshold, Six Labors lists Boutique at $799/year for up to 10 developers, with a build-time license key on current versions. That is a fair price for a full imaging suite. It is a lot if all you needed was "turn this upload into a 1280px WebP under 180 KB."
What PicReady does
PicReady wraps that upload → ready-to-serve path:
using PicReady;
PicReadyLicense.Register(licenseKey);
var webp = PicJob.Load(upload)
.Resize(960, 960, FitMode.Cover)
.Webp(80)
.ToBytes();
Under the hood it still uses SkiaSharp. It applies EXIF orientation and strips metadata by default, supports Contain / Cover / Stretch / MaxWidth / MaxHeight, circle and rounded-rect avatars, text and image watermarks, ResizeMany for responsive sets, MaxBytes quality search, batch processing with a parallelism bound, and ASP.NET Core IFormFile helpers. Hard safety caps reject oversized headers before Skia allocates a decoded bitmap.
Install Pro:
dotnet add package PicReady
If you only need a plain JPEG/PNG resize, the free MIT sibling PicReady.Lite has the same Contain/Cover fit API and no license key:
dotnet add package PicReady.Lite
using PicReady;
var thumb = LiteJob.Load(upload)
.Resize(480, 480, FitMode.Contain)
.Background(RgbColor.White)
.Jpeg(JpegPreset.Medium)
.ToBytes();
Both packages target .NET 8 and .NET 9. There is no System.Drawing and no ImageSharp dependency. Native Linux assets come from SkiaSharp.NativeAssets.Linux.NoDependencies, so a normal dotnet restore on Docker/Linux does not need a system libfontconfig.
A few friendly examples
Avatar upload, WebP under 100 KB. Cover-fit into a square, clear the corners, then binary-search quality until the file fits the budget:
var avatar = PicJob.Load(upload)
.Circle(256)
.MaxBytes(100 * 1024, OutputFormat.Webp)
.ToBytes();
PNG and WebP keep the outside of the circle transparent. JPEG paints AvatarBackground (transparent becomes white). If even the lowest allowed quality is still too big, MaxBytes throws and names the budget and the smallest size it reached.
Product images at 320, 640, and 1280. One decode, then one encode per longest side, in the order you asked for:
var set = PicJob.Load(upload)
.Png()
.ResizeMany(320, 640, 1280);
Strip camera EXIF. Orientation is applied by default, then metadata is dropped, so a phone photo is stored upright and a later viewer does not rotate it again:
var stored = Pic.Process(upload, new PicSettings
{
Width = 1600,
Fit = FitMode.MaxWidth,
Format = OutputFormat.Jpeg,
Quality = 85
});
var info = Pic.GetInfo(stored);
// info.HasExif is false. info.ExifOrientation is 1.
ASP.NET Core IFormFile. The content type comes from the bytes you asked to encode, not from the filename:
using PicReady.AspNetCore;
app.MapPost("/photos", async (IFormFile file) =>
{
var settings = new PicSettings
{
Width = 1280,
Fit = FitMode.MaxWidth,
Format = OutputFormat.Webp,
MaxBytes = 180 * 1024
};
var bytes = await PicFormFile.ProcessAsync(file, settings);
return Results.File(bytes, PicContent.GetContentType(settings.Format));
});
An empty upload throws. PicContent.TryDetect sniffs JPEG, PNG, GIF, and WebP headers.
Batch a folder. A bad file is reported on that index; the rest of the batch continues:
var files = Directory.GetFiles(inbox, "*.jpg");
var results = await Pic.BatchProcessAsync(
files,
path => Path.Combine(outbox, Path.GetFileNameWithoutExtension(path) + ".webp"),
new PicSettings { Width = 1600, Fit = FitMode.MaxWidth, Format = OutputFormat.Webp, Quality = 80 },
maxDegreeOfParallelism: 4);
foreach (var item in results.Where(item => !item.Success))
Console.Error.WriteLine($"{item.SourcePath}: {item.Error}");
Contain, Cover, and Stretch
| Mode | Result |
|---|---|
Contain |
The whole image is inside the box. The leftover area is Background. Nothing is cropped. |
Cover |
The box is filled. Overflow is cropped using Gravity or a focal point. No letterbox. |
Stretch |
Exact box. Aspect ratio is not kept. |
MaxWidth / MaxHeight
|
One side is fixed; the other follows the aspect ratio. |
Protection against decompression bombs
Before Skia allocates a decoded bitmap, PicReady checks the header against hard caps: longest side 16384, 80 million pixels, 200 MB input. Defaults are lower (8192, 40 million pixels, 80 MB). Raising a setting never goes past the hard cap. A header over the cap throws a managed PicException instead of letting the process balloon.
Numbers from one stopwatch run
These are from a Release stopwatch harness (one warmup, five iterations, median) on our benchmark machine: 4 processors, source 1280×720 noise, output 640×360 Contain. This is not a BenchmarkDotNet suite and not a laptop claim. Your hardware will differ.
| Pipeline | .NET 8.0.33 | .NET 9.0.22 |
|---|---|---|
| Decode, resize, JPEG quality 80 | 46.4 ms | 48.0 ms |
| Decode, resize, WebP quality 80 | 74.3 ms | 76.8 ms |
Same JPEG with MaxBytes 80 KB |
135.7 ms | 134.3 ms |
MaxBytes encodes several qualities on purpose, so it is slower than a single encode.
When you should not use this
If you need a full pixel API, custom shaders, or ImageSharp's general imaging surface, PicReady is the wrong tool. It is built for upload → oriented, fitted, encoded file.
If you only need a plain resize to JPEG or PNG and you want zero licensing, start with Lite instead of Pro.
If you need HEIC, AVIF, TIFF, animated GIF encode, lossless WebP, or CMYK output, this package will not help you. GIF and animated WebP decode the first frame only. CMYK JPEG is decoded to RGB.
If you are already happy with Magick.NET's native stack or with ImageSharp's suite and its commercial terms, keep them. PicReady is meant for the teams that wanted a smaller, cheaper upload helper — not a drop-in ImageSharp replacement.
Try it without buying anything
PicReady.Lite is MIT licensed: load from path / stream / bytes, fit modes, JPEG and PNG only, fixed quality presets.
dotnet add package PicReady.Lite
PicReady adds WebP, EXIF orient+strip, MaxBytes, crop/focal, avatars, watermarks, ResizeMany, batch, async parity, and IFormFile helpers. You can try it without a key: every output is still a valid JPEG/PNG/WebP of the same dimensions, with a small corner "PicReady trial" mark. Outside Development, a 14-day window starts on first use; Development does not expire that window.
dotnet add package PicReady
When you have a key, register it at startup or set an environment variable. A missing variable leaves the library in trial mode. It does not throw.
using PicReady;
PicReadyLicense.Register(builder.Configuration["PicReady:LicenseKey"]!);
export PICREADY_LICENSE_KEY="paste-the-key-here"
You can also drop the token in a picready.lic file next to the app, or in license.key under the PicReady app-data folder. The key is checked offline; the package does not phone home. An invalid key fails closed with a clear message; a valid key already active stays in place.
| PicReady.Lite | PicReady | |
|---|---|---|
| License | MIT | Commercial EULA, offline key |
| Decode | JPEG, PNG, GIF (first frame) | Same, plus WebP |
| Encode | JPEG, PNG | JPEG, PNG, WebP |
| Fit modes | Contain, Cover, Stretch, MaxWidth, MaxHeight | Same |
| EXIF orient and strip | No | Yes |
Crop, avatar, watermark, MaxBytes, batch, IFormFile
|
No | Yes |
If Pro fits, licenses are $19 per developer per year (1–50 seats) or $79 per year for a team of up to 10 developers. The key is emailed within about an hour of checkout. Questions and feedback are welcome at dbdavis1990apple@gmail.com.
I built this because I was tired of choosing between a raw SkiaSharp paste and a commercial imaging suite priced for a much bigger job. If it turns one phone upload into a file you can actually serve, it did what I hoped.
Top comments (0)