How to resize and compress images (with Dianoga) during upload to Sitecore media library

A bit of theory, TL;DR;

Images optimisation is the hot topic for me recently. Last week I debugged one issue related to media cache: for URL without a ?mw parameter, an image was always served from a database even if a cache file existed inside the MediaCache folder. Obviously this, with Dianoga module (where image optimisation for some large images can take a few seconds) and a large number of visitors, can lead to performance problems.

It turned out that we have a processor inside the getMediaStream pipeline that was intended to resize image during upload if the image width is greater than some specified max width. The issue was tough to spot because it really looked like it works correctly: the width and height fields of a media item were set correctly during upload, and when you downloaded the image you could not get it larger than a max width (even without any parameters in URL). But it obviously didn’t work because when I turned the processor off, cleared the media cache folder and download the image again, I got it in full-size. We spotted that issue only because we installed Dianoga and saw in logs that for some images Dianoga adds lines every time you request an image.

When you upload the image to media library then an uiUpload pipeline is executed. Inside that pipeline, there is a Sitecore.Pipelines.Upload.Save processor and inside that processor, for each uploaded image (single or unpacked form zip) the MediaCreator.CreateFromStream method is executed:

public virtual Item CreateFromStream(Stream stream, string filePath, bool setStreamIfEmpty, MediaCreatorOptions options)
{
    Assert.ArgumentNotNull((object) stream, nameof (stream));
    Assert.ArgumentNotNullOrEmpty(filePath, nameof (filePath));
    Assert.ArgumentNotNull((object) options, nameof (options));
    string itemPath = this.GetItemPath(filePath, options);
    return this.AttachStreamToMediaItem(stream, itemPath, filePath, options);
}

It gets item path and then calls MediaStream.AttachStreamToMediaItem:

public virtual Item AttachStreamToMediaItem(Stream stream, string itemPath, string fileName, MediaCreatorOptions options)
{
    Assert.ArgumentNotNull((object) stream, nameof (stream));
    Assert.ArgumentNotNullOrEmpty(fileName, nameof (fileName));
    Assert.ArgumentNotNull((object) options, nameof (options));
    Assert.ArgumentNotNull((object) itemPath, nameof (itemPath));
    Sitecore.Resources.Media.Media media = MediaManager.GetMedia((MediaItem) this.CreateItem(itemPath, fileName, options));
    media.SetStream(stream, FileUtil.GetExtension(fileName));
    return (Item) media.MediaData.MediaItem;
}

First, the media item is created then the MediaStream.SetStreammethod is executed:

public virtual void SetStream(MediaStream mediaStream)
{
    Assert.ArgumentNotNull((object) mediaStream, nameof (mediaStream));
    this.MediaData.SetStream(mediaStream);
    this.UpdateMetaData(mediaStream);
}

The stream is set to the media item, and then metadata is updated inside ImageMedia.UpdateMetaData method:

public override void UpdateMetaData(MediaStream mediaStream)
{
    base.UpdateMetaData(mediaStream);
    this.UpdateImageMetaData(mediaStream);
}

And finally UpdateImageMetaData looks like this:

protected virtual void UpdateImageMetaData(MediaStream mediaStream)
{
    Assert.ArgumentNotNull((object) mediaStream, nameof (mediaStream));
    if (!mediaStream.AllowMemoryLoading)
    {
        Tracer.Error((object) "Could not update image meta data as the image is larger than the maximum size allowed for memory processing. Media item: {0}", (object) mediaStream.MediaItem.Path);
    }
    else
    {
        Item innerItem = this.MediaData.MediaItem.InnerItem;
        using (new EditContext(innerItem, SecurityCheck.Disable))
        {
            using (Image image = this.GetImage())
            {
                if (image == null)
                    return;
                innerItem["Width"] = image.Width.ToString();
                innerItem["Height"] = image.Height.ToString();
                innerItem["Dimensions"] = string.Format("{0} x {1}", (object) image.Width, (object) image.Height);
            }
        }
    }
}

As you can see the UpdateImageMetaData sets Width and Height of the image, but it does not use originally uploaded stream. Instead, it calls GetImage method which in the end tries to get an image from MediaCache folder or directly from Sitecore by calling the getMediaStream pipeline. That pipeline returned image transformed by MaxWidth parameter added by our custom processor I mentioned at the beginning. Width and Height fields were set from the size of the transformed image, not the original one. I looked correct during upload and when you requested image in the browser, but in the database, the stream with original size has been uploaded.

A Solution

After a lot of digging into Sitecore assemblies with DotPeek, I decided to put my code into MediaCreator.AttachStreamToMediaItem method. MediaCreator.CreateFromStream would be better (because of the name and not only), but Dianoga processors use MediaStream and MediaItem objects, so we need MediaItem to be created first. So I created CustomMediaCreator class and put my code after MediaItem creation and before the stream is set onto the item:

public class CustomMediaCreator : MediaCreator
{
    public override Item AttachStreamToMediaItem(Stream stream, string itemPath, string fileName, MediaCreatorOptions options)
    {
        Assert.ArgumentNotNull((object)stream, nameof(stream));
        Assert.ArgumentNotNullOrEmpty(fileName, nameof(fileName));
        Assert.ArgumentNotNull((object)options, nameof(options));
        Assert.ArgumentNotNull((object)itemPath, nameof(itemPath));

        var media = MediaManager.GetMedia((MediaItem)this.CreateItem(itemPath, fileName, options));

        // Customisation
        try
        {
            var mediaManager = ServiceLocator.ServiceProvider.GetRequiredService();

            var extension = FileUtil.GetExtension(fileName);
            var mimeType = mediaManager.MimeResolver.GetMimeType(extension);

            var args = new UploadMediaStreamPipelineArgs(stream, fileName, extension, mimeType, media.MediaData.MediaItem);
            CorePipeline.Run("uploadMediaStream", args);

            stream = args.Stream;
        }
        catch (Exception ex)
        {
            Log.Error("Could not run the 'uploadMediaStream' pipeline for '" + fileName, ex, this);
        }
        // Customisation ends here

        media.SetStream(stream, FileUtil.GetExtension(fileName));
        return (Item)media.MediaData.MediaItem;
    }
}

My customisation basically creates UploadMediaStreamPipelineArgs pipeline args object and then runs custom uploadMediaStream pipeline. Inside that pipeline, I decided to have two processors: the first one that resizes the image if it's greater than the maximum width and the second one that optimises the image with Dianoga. Configuration patch for media creator and pipeline looks like this:

<sitecore>   
  <mediaLibrary>
    <mediaCreator type="Namespace.CustomMediaCreator, AssemblyName" />
  </mediaLibrary>
  <pipelines>
    <uploadMediaStream> <!-- custom pipeline -->
      <processor type="Namespace.UploadMediaStream.Resize, AssemblyName" />
      <processor type="Namespace.UploadMediaStream.DianogaOptimize, AssemblyName " />
    </uploadMediaStream>
  </pipelines>
</sitecore>

Definition of the UploadMediaStreamPipelineArgs looks like this:

[Serializable]
public class UploadMediaStreamPipelineArgs : PipelineArgs
{
    public Stream Stream { get; set; }
    public string FilePath { get; }
    public string Extension { get; }
    public string MimeType { get; }
    public MediaItem MediaItem { get; }

    public UploadMediaStreamPipelineArgs(Stream stream, string filePath, string extension, string mimeType, MediaItem mediaItem)
    {
        Stream = stream;
        FilePath = filePath;
        Extension = extension;
        MimeType = mimeType;
        MediaItem = mediaItem;
    }
}

The code of Resize processor is copied from Sitecore.Resources.Media.ResizeProcessor and modified a bit because the original one takes media stream directly from media item and at this stage that stream is not set yet:

public class Resize
{
    public virtual void Process(UploadMediaStreamPipelineArgs args)
    {
        if (args.Stream != null && args.MimeType.StartsWith("image/", StringComparison.Ordinal) && args.Stream.CanRead && args.Stream.Length <= Settings.Media.MaxSizeInMemory)
        {
            if (args.Stream.CanSeek)
            {
                args.Stream.Seek(0L, SeekOrigin.Begin);
            }

            var mediaManager = ServiceLocator.ServiceProvider.GetRequiredService();

            var imageFormat = mediaManager.Config.GetImageFormat(args.Extension, null);
            if (imageFormat != null)
            {
                var transformationOptions = new TransformationOptions { MaxSize = new Size(MaxWidthProcessor.MaxWidth, 0) };

                // input stream is closed inside TransformImageStream
                args.Stream = mediaManager.Effects.TransformImageStream(args.Stream, transformationOptions, imageFormat);
            }
        }
    }
}

And finally the DianogaOptimize processor. It creates a MediaOptimizer object and a MediaStream object and passes them into Process method. If the stream is returned, the input one is closed and replaced by optimised one. The code looks like this:

public class DianogaOptimize
{
    public virtual void Process(UploadMediaStreamPipelineArgs args)
    {
        if (args.Stream != null && args.Stream.CanRead && args.Stream.Length <= Settings.Media.MaxSizeInMemory)
        {
            if (args.Stream.CanSeek)
            {
                args.Stream.Seek(0L, SeekOrigin.Begin);
            }

            var dianogaOptimizer = new MediaOptimizer();
            var mediaStream = new MediaStream(args.Stream, args.Extension, args.MediaItem);

            var optimizedOutputStream = dianogaOptimizer.Process(mediaStream, new MediaOptions { });
            if (optimizedOutputStream?.Stream != null)
            {
                args.Stream.Close();
                args.Stream = optimizedOutputStream.Stream;
            }
        }
    }
}

Then I did a print screen of my 4k monitor and saved it as a png file. It was around 5mb. After Resize to 1920 it was 3mb, and after DianogaOptimize compressed it lossless, it was 1.6MB. Quite nice results.