Browse Source

Reduced intermediate allocations: Jpeg

pull/2415/head
Günther Foidl 3 years ago
parent
commit
858a8485b7
  1. 2
      src/ImageSharp/Formats/Jpeg/Components/Decoder/AdobeMarker.cs
  2. 2
      src/ImageSharp/Formats/Jpeg/Components/Decoder/JFifMarker.cs
  3. 146
      src/ImageSharp/Formats/Jpeg/JpegDecoderCore.cs
  4. 289
      src/ImageSharp/Formats/Jpeg/JpegEncoderCore.cs

2
src/ImageSharp/Formats/Jpeg/Components/Decoder/AdobeMarker.cs

@ -62,7 +62,7 @@ internal readonly struct AdobeMarker : IEquatable<AdobeMarker>
/// </summary>
/// <param name="bytes">The byte array containing metadata to parse.</param>
/// <param name="marker">The marker to return.</param>
public static bool TryParse(byte[] bytes, out AdobeMarker marker)
public static bool TryParse(ReadOnlySpan<byte> bytes, out AdobeMarker marker)
{
if (ProfileResolver.IsProfile(bytes, ProfileResolver.AdobeMarker))
{

2
src/ImageSharp/Formats/Jpeg/Components/Decoder/JFifMarker.cs

@ -69,7 +69,7 @@ internal readonly struct JFifMarker : IEquatable<JFifMarker>
/// </summary>
/// <param name="bytes">The byte array containing metadata to parse.</param>
/// <param name="marker">The marker to return.</param>
public static bool TryParse(byte[] bytes, out JFifMarker marker)
public static bool TryParse(ReadOnlySpan<byte> bytes, out JFifMarker marker)
{
if (ProfileResolver.IsProfile(bytes, ProfileResolver.JFifMarker))
{

146
src/ImageSharp/Formats/Jpeg/JpegDecoderCore.cs

@ -27,21 +27,6 @@ namespace SixLabors.ImageSharp.Formats.Jpeg;
/// </summary>
internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
{
/// <summary>
/// The only supported precision
/// </summary>
private readonly byte[] supportedPrecisions = { 8, 12 };
/// <summary>
/// The buffer used to temporarily store bytes read from the stream.
/// </summary>
private readonly byte[] temp = new byte[2 * 16 * 4];
/// <summary>
/// The buffer used to read markers from the stream.
/// </summary>
private readonly byte[] markerBuffer = new byte[2];
/// <summary>
/// Whether the image has an EXIF marker.
/// </summary>
@ -139,6 +124,12 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
this.skipMetadata = options.GeneralOptions.SkipMetadata;
}
/// <summary>
/// The only supported precision
/// </summary>
// Refers to assembly's static data segment, no allocation occurs.
private static ReadOnlySpan<byte> SupportedPrecisions => new byte[] { 8, 12 };
/// <inheritdoc />
public DecoderOptions Options { get; }
@ -257,24 +248,26 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
using MemoryStream ms = new(tableBytes);
using BufferedReadStream stream = new(this.configuration, ms);
Span<byte> markerBuffer = stackalloc byte[2];
// Check for the Start Of Image marker.
int bytesRead = stream.Read(this.markerBuffer, 0, 2);
JpegFileMarker fileMarker = new(this.markerBuffer[1], 0);
int bytesRead = stream.Read(markerBuffer);
JpegFileMarker fileMarker = new(markerBuffer[1], 0);
if (fileMarker.Marker != JpegConstants.Markers.SOI)
{
JpegThrowHelper.ThrowInvalidImageContentException("Missing SOI marker.");
}
// Read next marker.
bytesRead = stream.Read(this.markerBuffer, 0, 2);
fileMarker = new JpegFileMarker(this.markerBuffer[1], (int)stream.Position - 2);
bytesRead = stream.Read(markerBuffer);
fileMarker = new JpegFileMarker(markerBuffer[1], (int)stream.Position - 2);
while (fileMarker.Marker != JpegConstants.Markers.EOI || (fileMarker.Marker == JpegConstants.Markers.EOI && fileMarker.Invalid))
{
if (!fileMarker.Invalid)
{
// Get the marker length.
int markerContentByteSize = this.ReadUint16(stream) - 2;
int markerContentByteSize = ReadUint16(stream, markerBuffer) - 2;
// Check whether the stream actually has enough bytes to read
// markerContentByteSize is always positive so we cast
@ -297,7 +290,7 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
this.ProcessDefineQuantizationTablesMarker(stream, markerContentByteSize);
break;
case JpegConstants.Markers.DRI:
this.ProcessDefineRestartIntervalMarker(stream, markerContentByteSize);
this.ProcessDefineRestartIntervalMarker(stream, markerContentByteSize, markerBuffer);
break;
case JpegConstants.Markers.EOI:
return;
@ -305,13 +298,13 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
}
// Read next marker.
bytesRead = stream.Read(this.markerBuffer, 0, 2);
bytesRead = stream.Read(markerBuffer);
if (bytesRead != 2)
{
JpegThrowHelper.ThrowInvalidImageContentException("Not enough data to read marker");
}
fileMarker = new JpegFileMarker(this.markerBuffer[1], 0);
fileMarker = new JpegFileMarker(markerBuffer[1], 0);
}
}
@ -329,9 +322,11 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
this.Metadata = new ImageMetadata();
Span<byte> markerBuffer = stackalloc byte[2];
// Check for the Start Of Image marker.
stream.Read(this.markerBuffer, 0, 2);
JpegFileMarker fileMarker = new(this.markerBuffer[1], 0);
stream.Read(markerBuffer);
JpegFileMarker fileMarker = new(markerBuffer[1], 0);
if (fileMarker.Marker != JpegConstants.Markers.SOI)
{
JpegThrowHelper.ThrowInvalidImageContentException("Missing SOI marker.");
@ -349,7 +344,7 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
if (!fileMarker.Invalid)
{
// Get the marker length.
int markerContentByteSize = this.ReadUint16(stream) - 2;
int markerContentByteSize = ReadUint16(stream, markerBuffer) - 2;
// Check whether stream actually has enough bytes to read
// markerContentByteSize is always positive so we cast
@ -446,7 +441,7 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
}
else
{
this.ProcessDefineRestartIntervalMarker(stream, markerContentByteSize);
this.ProcessDefineRestartIntervalMarker(stream, markerContentByteSize, markerBuffer);
}
break;
@ -755,8 +750,10 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
return;
}
stream.Read(this.temp, 0, JFifMarker.Length);
if (!JFifMarker.TryParse(this.temp, out this.jFif))
Span<byte> temp = stackalloc byte[2 * 16 * 4];
stream.Read(temp, 0, JFifMarker.Length);
if (!JFifMarker.TryParse(temp, out this.jFif))
{
JpegThrowHelper.ThrowNotSupportedException("Unknown App0 Marker - Expected JFIF.");
}
@ -796,11 +793,13 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
JpegThrowHelper.ThrowInvalidImageContentException("Bad App1 Marker length.");
}
Span<byte> temp = stackalloc byte[2 * 16 * 4];
// XMP marker is the longer then the EXIF marker, so first try read the EXIF marker bytes.
stream.Read(this.temp, 0, exifMarkerLength);
stream.Read(temp, 0, exifMarkerLength);
remaining -= exifMarkerLength;
if (ProfileResolver.IsProfile(this.temp, ProfileResolver.ExifMarker))
if (ProfileResolver.IsProfile(temp, ProfileResolver.ExifMarker))
{
this.hasExif = true;
byte[] profile = new byte[remaining];
@ -819,7 +818,7 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
remaining = 0;
}
if (ProfileResolver.IsProfile(this.temp, ProfileResolver.XmpMarker[..exifMarkerLength]))
if (ProfileResolver.IsProfile(temp, ProfileResolver.XmpMarker[..exifMarkerLength]))
{
const int remainingXmpMarkerBytes = xmpMarkerLength - exifMarkerLength;
if (remaining < remainingXmpMarkerBytes || this.skipMetadata)
@ -829,9 +828,9 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
return;
}
stream.Read(this.temp, exifMarkerLength, remainingXmpMarkerBytes);
stream.Read(temp, exifMarkerLength, remainingXmpMarkerBytes);
remaining -= remainingXmpMarkerBytes;
if (ProfileResolver.IsProfile(this.temp, ProfileResolver.XmpMarker))
if (ProfileResolver.IsProfile(temp, ProfileResolver.XmpMarker))
{
this.hasXmp = true;
byte[] profile = new byte[remaining];
@ -870,8 +869,8 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
return;
}
byte[] identifier = new byte[icclength];
stream.Read(identifier, 0, icclength);
Span<byte> identifier = stackalloc byte[icclength];
stream.Read(identifier);
remaining -= icclength; // We have read it by this point
if (ProfileResolver.IsProfile(identifier, ProfileResolver.IccMarker))
@ -911,13 +910,13 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
return;
}
stream.Read(this.temp, 0, ProfileResolver.AdobePhotoshopApp13Marker.Length);
Span<byte> temp = stackalloc byte[2 * 16 * 4];
stream.Read(temp, 0, ProfileResolver.AdobePhotoshopApp13Marker.Length);
remaining -= ProfileResolver.AdobePhotoshopApp13Marker.Length;
if (ProfileResolver.IsProfile(this.temp, ProfileResolver.AdobePhotoshopApp13Marker))
if (ProfileResolver.IsProfile(temp, ProfileResolver.AdobePhotoshopApp13Marker))
{
byte[] resourceBlockData = new byte[remaining];
stream.Read(resourceBlockData, 0, remaining);
Span<byte> blockDataSpan = resourceBlockData.AsSpan();
Span<byte> blockDataSpan = remaining <= 128 ? stackalloc byte[remaining] : new byte[remaining];
stream.Read(blockDataSpan);
while (blockDataSpan.Length > 12)
{
@ -1047,10 +1046,12 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
return;
}
stream.Read(this.temp, 0, markerLength);
Span<byte> temp = stackalloc byte[2 * 16 * 4];
stream.Read(temp, 0, markerLength);
remaining -= markerLength;
if (AdobeMarker.TryParse(this.temp, out this.adobe))
if (AdobeMarker.TryParse(temp, out this.adobe))
{
this.hasAdobeMarker = true;
}
@ -1072,6 +1073,7 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
private void ProcessDefineQuantizationTablesMarker(BufferedReadStream stream, int remaining)
{
JpegMetadata jpegMetadata = this.Metadata.GetFormatMetadata(JpegFormat.Instance);
Span<byte> temp = stackalloc byte[2 * 16 * 4];
while (remaining > 0)
{
@ -1102,13 +1104,13 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
JpegThrowHelper.ThrowBadMarker(nameof(JpegConstants.Markers.DQT), remaining);
}
stream.Read(this.temp, 0, 64);
stream.Read(temp, 0, 64);
remaining -= 64;
// Parsing quantization table & saving it in natural order
for (int j = 0; j < 64; j++)
{
table[ZigZag.ZigZagOrder[j]] = this.temp[j];
table[ZigZag.ZigZagOrder[j]] = temp[j];
}
break;
@ -1121,13 +1123,13 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
JpegThrowHelper.ThrowBadMarker(nameof(JpegConstants.Markers.DQT), remaining);
}
stream.Read(this.temp, 0, 128);
stream.Read(temp, 0, 128);
remaining -= 128;
// Parsing quantization table & saving it in natural order
for (int j = 0; j < 64; j++)
{
table[ZigZag.ZigZagOrder[j]] = (this.temp[2 * j] << 8) | this.temp[(2 * j) + 1];
table[ZigZag.ZigZagOrder[j]] = (temp[2 * j] << 8) | temp[(2 * j) + 1];
}
break;
@ -1174,28 +1176,30 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
JpegThrowHelper.ThrowInvalidImageContentException("Multiple SOF markers. Only single frame jpegs supported.");
}
Span<byte> temp = stackalloc byte[2 * 16 * 4];
// Read initial marker definitions.
const int length = 6;
int bytesRead = stream.Read(this.temp, 0, length);
int bytesRead = stream.Read(temp, 0, length);
if (bytesRead != length)
{
JpegThrowHelper.ThrowInvalidImageContentException("SOF marker does not contain enough data.");
}
// 1 byte: Bits/sample precision.
byte precision = this.temp[0];
byte precision = temp[0];
// Validate: only 8-bit and 12-bit precisions are supported.
if (Array.IndexOf(this.supportedPrecisions, precision) == -1)
if (SupportedPrecisions.IndexOf(precision) < 0)
{
JpegThrowHelper.ThrowInvalidImageContentException("Only 8-Bit and 12-Bit precision is supported.");
}
// 2 byte: Height
int frameHeight = (this.temp[1] << 8) | this.temp[2];
int frameHeight = (temp[1] << 8) | temp[2];
// 2 byte: Width
int frameWidth = (this.temp[3] << 8) | this.temp[4];
int frameWidth = (temp[3] << 8) | temp[4];
// Validate: width/height > 0 (they are upper-bounded by 2 byte max value so no need to check that).
if (frameHeight == 0 || frameWidth == 0)
@ -1204,7 +1208,7 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
}
// 1 byte: Number of components.
byte componentCount = this.temp[5];
byte componentCount = temp[5];
// Validate: componentCount more than 4 can lead to a buffer overflow during stream
// reading so we must limit it to 4.
@ -1227,7 +1231,7 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
}
// components*3 bytes: component data
stream.Read(this.temp, 0, remaining);
stream.Read(temp, 0, remaining);
// No need to pool this. They max out at 4
this.Frame.ComponentIds = new byte[componentCount];
@ -1240,10 +1244,10 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
for (int i = 0; i < this.Frame.Components.Length; i++)
{
// 1 byte: component identifier
byte componentId = this.temp[index];
byte componentId = temp[index];
// 1 byte: component sampling factors
byte hv = this.temp[index + 1];
byte hv = temp[index + 1];
int h = (hv >> 4) & 15;
int v = hv & 15;
@ -1270,7 +1274,7 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
}
// 1 byte: quantization table destination selector
byte quantTableIndex = this.temp[index + 2];
byte quantTableIndex = temp[index + 2];
// Validate: 0-3 range
if (quantTableIndex > 3)
@ -1379,7 +1383,8 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
/// </summary>
/// <param name="stream">The input stream.</param>
/// <param name="remaining">The remaining bytes in the segment block.</param>
private void ProcessDefineRestartIntervalMarker(BufferedReadStream stream, int remaining)
/// <param name="markerBuffer">Scratch buffer.</param>
private void ProcessDefineRestartIntervalMarker(BufferedReadStream stream, int remaining, Span<byte> markerBuffer)
{
if (remaining != 2)
{
@ -1388,7 +1393,7 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
// Save the reset interval, because it can come before or after the SOF marker.
// If the reset interval comes after the SOF marker, the scanDecoder has not been created.
this.resetInterval = this.ReadUint16(stream);
this.resetInterval = ReadUint16(stream, markerBuffer);
if (this.scanDecoder != null)
{
@ -1425,14 +1430,16 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
JpegThrowHelper.ThrowBadMarker(nameof(JpegConstants.Markers.SOS), remaining);
}
Span<byte> temp = stackalloc byte[2 * 16 * 4];
// selectorsCount*2 bytes: component index + huffman tables indices
stream.Read(this.temp, 0, selectorsBytes);
stream.Read(temp, 0, selectorsBytes);
this.Frame.Interleaved = this.Frame.ComponentCount == selectorsCount;
for (int i = 0; i < selectorsBytes; i += 2)
{
// 1 byte: Component id
int componentSelectorId = this.temp[i];
int componentSelectorId = temp[i];
int componentIndex = -1;
for (int j = 0; j < this.Frame.ComponentIds.Length; j++)
@ -1459,7 +1466,7 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
// 1 byte: Huffman table selectors.
// 4 bits - dc
// 4 bits - ac
int tableSpec = this.temp[i + 1];
int tableSpec = temp[i + 1];
int dcTableIndex = tableSpec >> 4;
int acTableIndex = tableSpec & 15;
@ -1475,17 +1482,17 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
}
// 3 bytes: Progressive scan decoding data.
int bytesRead = stream.Read(this.temp, 0, 3);
int bytesRead = stream.Read(temp, 0, 3);
if (bytesRead != 3)
{
JpegThrowHelper.ThrowInvalidImageContentException("Not enough data to read progressive scan decoding data");
}
this.scanDecoder.SpectralStart = this.temp[0];
this.scanDecoder.SpectralStart = temp[0];
this.scanDecoder.SpectralEnd = this.temp[1];
this.scanDecoder.SpectralEnd = temp[1];
int successiveApproximation = this.temp[2];
int successiveApproximation = temp[2];
this.scanDecoder.SuccessiveHigh = successiveApproximation >> 4;
this.scanDecoder.SuccessiveLow = successiveApproximation & 15;
@ -1501,16 +1508,17 @@ internal sealed class JpegDecoderCore : IRawJpegData, IImageDecoderInternals
/// Reads a <see cref="ushort"/> from the stream advancing it by two bytes.
/// </summary>
/// <param name="stream">The input stream.</param>
/// <param name="markerBuffer">The scratch buffer used for reading from the stream.</param>
/// <returns>The <see cref="ushort"/></returns>
[MethodImpl(InliningOptions.ShortMethod)]
private ushort ReadUint16(BufferedReadStream stream)
private static ushort ReadUint16(BufferedReadStream stream, Span<byte> markerBuffer)
{
int bytesRead = stream.Read(this.markerBuffer, 0, 2);
int bytesRead = stream.Read(markerBuffer, 0, 2);
if (bytesRead != 2)
{
JpegThrowHelper.ThrowInvalidImageContentException("jpeg stream does not contain enough data, could not read ushort.");
}
return BinaryPrimitives.ReadUInt16BigEndian(this.markerBuffer);
return BinaryPrimitives.ReadUInt16BigEndian(markerBuffer);
}
}

289
src/ImageSharp/Formats/Jpeg/JpegEncoderCore.cs

@ -25,11 +25,6 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
/// </summary>
private static readonly JpegFrameConfig[] FrameConfigs = CreateFrameConfigs();
/// <summary>
/// A scratch buffer to reduce allocations.
/// </summary>
private readonly byte[] buffer = new byte[20];
private readonly JpegEncoder encoder;
/// <summary>
@ -67,6 +62,7 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
cancellationToken.ThrowIfCancellationRequested();
this.outputStream = stream;
Span<byte> buffer = stackalloc byte[20];
ImageMetadata metadata = image.Metadata;
JpegMetadata jpegMetadata = metadata.GetJpegMetadata();
@ -76,39 +72,39 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
using JpegFrame frame = new(image, frameConfig, interleaved);
// Write the Start Of Image marker.
this.WriteStartOfImage();
this.WriteStartOfImage(buffer);
// Write APP0 marker
if (frameConfig.AdobeColorTransformMarkerFlag is null)
{
this.WriteJfifApplicationHeader(metadata);
this.WriteJfifApplicationHeader(metadata, buffer);
}
// Write APP14 marker with adobe color extension
else
{
this.WriteApp14Marker(frameConfig.AdobeColorTransformMarkerFlag.Value);
this.WriteApp14Marker(frameConfig.AdobeColorTransformMarkerFlag.Value, buffer);
}
// Write Exif, XMP, ICC and IPTC profiles
this.WriteProfiles(metadata);
this.WriteProfiles(metadata, buffer);
// Write the image dimensions.
this.WriteStartOfFrame(image.Width, image.Height, frameConfig);
this.WriteStartOfFrame(image.Width, image.Height, frameConfig, buffer);
// Write the Huffman tables.
HuffmanScanEncoder scanEncoder = new(frame.BlocksPerMcu, stream);
this.WriteDefineHuffmanTables(frameConfig.HuffmanTables, scanEncoder);
this.WriteDefineHuffmanTables(frameConfig.HuffmanTables, scanEncoder, buffer);
// Write the quantization tables.
this.WriteDefineQuantizationTables(frameConfig.QuantizationTables, this.encoder.Quality, jpegMetadata);
this.WriteDefineQuantizationTables(frameConfig.QuantizationTables, this.encoder.Quality, jpegMetadata, buffer);
// Write scans with actual pixel data
using SpectralConverter<TPixel> spectralConverter = new(frame, image, this.QuantizationTables);
this.WriteHuffmanScans(frame, frameConfig, spectralConverter, scanEncoder, cancellationToken);
this.WriteHuffmanScans(frame, frameConfig, spectralConverter, scanEncoder, buffer, cancellationToken);
// Write the End Of Image marker.
this.WriteEndOfImageMarker();
this.WriteEndOfImageMarker(buffer);
stream.Flush();
}
@ -116,58 +112,59 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
/// <summary>
/// Write the start of image marker.
/// </summary>
private void WriteStartOfImage()
private void WriteStartOfImage(Span<byte> buffer)
{
// Markers are always prefixed with 0xff.
this.buffer[0] = JpegConstants.Markers.XFF;
this.buffer[1] = JpegConstants.Markers.SOI;
buffer[1] = JpegConstants.Markers.SOI;
buffer[0] = JpegConstants.Markers.XFF;
this.outputStream.Write(this.buffer, 0, 2);
this.outputStream.Write(buffer, 0, 2);
}
/// <summary>
/// Writes the application header containing the JFIF identifier plus extra data.
/// </summary>
/// <param name="meta">The image metadata.</param>
private void WriteJfifApplicationHeader(ImageMetadata meta)
/// <param name="buffer">Temporary buffer.</param>
private void WriteJfifApplicationHeader(ImageMetadata meta, Span<byte> buffer)
{
// Write the JFIF headers
this.buffer[0] = JpegConstants.Markers.XFF;
this.buffer[1] = JpegConstants.Markers.APP0; // Application Marker
this.buffer[2] = 0x00;
this.buffer[3] = 0x10;
this.buffer[4] = 0x4a; // J
this.buffer[5] = 0x46; // F
this.buffer[6] = 0x49; // I
this.buffer[7] = 0x46; // F
this.buffer[8] = 0x00; // = "JFIF",'\0'
this.buffer[9] = 0x01; // versionhi
this.buffer[10] = 0x01; // versionlo
// Write the JFIF headers (highest index first to avoid additional bound checks)
buffer[10] = 0x01; // versionlo
buffer[0] = JpegConstants.Markers.XFF;
buffer[1] = JpegConstants.Markers.APP0; // Application Marker
buffer[2] = 0x00;
buffer[3] = 0x10;
buffer[4] = 0x4a; // J
buffer[5] = 0x46; // F
buffer[6] = 0x49; // I
buffer[7] = 0x46; // F
buffer[8] = 0x00; // = "JFIF",'\0'
buffer[9] = 0x01; // versionhi
// Resolution. Big Endian
Span<byte> hResolution = this.buffer.AsSpan(12, 2);
Span<byte> vResolution = this.buffer.AsSpan(14, 2);
Span<byte> hResolution = buffer.Slice(12, 2);
Span<byte> vResolution = buffer.Slice(14, 2);
if (meta.ResolutionUnits == PixelResolutionUnit.PixelsPerMeter)
{
// Scale down to PPI
this.buffer[11] = (byte)PixelResolutionUnit.PixelsPerInch; // xyunits
buffer[11] = (byte)PixelResolutionUnit.PixelsPerInch; // xyunits
BinaryPrimitives.WriteInt16BigEndian(hResolution, (short)Math.Round(UnitConverter.MeterToInch(meta.HorizontalResolution)));
BinaryPrimitives.WriteInt16BigEndian(vResolution, (short)Math.Round(UnitConverter.MeterToInch(meta.VerticalResolution)));
}
else
{
// We can simply pass the value.
this.buffer[11] = (byte)meta.ResolutionUnits; // xyunits
buffer[11] = (byte)meta.ResolutionUnits; // xyunits
BinaryPrimitives.WriteInt16BigEndian(hResolution, (short)Math.Round(meta.HorizontalResolution));
BinaryPrimitives.WriteInt16BigEndian(vResolution, (short)Math.Round(meta.VerticalResolution));
}
// No thumbnail
this.buffer[16] = 0x00; // Thumbnail width
this.buffer[17] = 0x00; // Thumbnail height
buffer[17] = 0x00; // Thumbnail height
buffer[16] = 0x00; // Thumbnail width
this.outputStream.Write(this.buffer, 0, 18);
this.outputStream.Write(buffer, 0, 18);
}
/// <summary>
@ -175,8 +172,9 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
/// </summary>
/// <param name="tableConfigs">The table configuration.</param>
/// <param name="scanEncoder">The scan encoder.</param>
/// <param name="buffer">Temporary buffer.</param>
/// <exception cref="ArgumentNullException"><paramref name="tableConfigs"/> is <see langword="null"/>.</exception>
private void WriteDefineHuffmanTables(JpegHuffmanTableConfig[] tableConfigs, HuffmanScanEncoder scanEncoder)
private void WriteDefineHuffmanTables(JpegHuffmanTableConfig[] tableConfigs, HuffmanScanEncoder scanEncoder, Span<byte> buffer)
{
if (tableConfigs is null)
{
@ -190,7 +188,7 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
markerlen += 1 + 16 + tableConfigs[i].Table.Values.Length;
}
this.WriteMarkerHeader(JpegConstants.Markers.DHT, markerlen);
this.WriteMarkerHeader(JpegConstants.Markers.DHT, markerlen, buffer);
for (int i = 0; i < tableConfigs.Length; i++)
{
JpegHuffmanTableConfig tableConfig = tableConfigs[i];
@ -208,37 +206,39 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
/// Writes the APP14 marker to indicate the image is in RGB color space.
/// </summary>
/// <param name="colorTransform">The color transform byte.</param>
private void WriteApp14Marker(byte colorTransform)
/// <param name="buffer">Temporary buffer.</param>
private void WriteApp14Marker(byte colorTransform, Span<byte> buffer)
{
this.WriteMarkerHeader(JpegConstants.Markers.APP14, 2 + Components.Decoder.AdobeMarker.Length);
this.WriteMarkerHeader(JpegConstants.Markers.APP14, 2 + Components.Decoder.AdobeMarker.Length, buffer);
// Identifier: ASCII "Adobe".
this.buffer[0] = 0x41;
this.buffer[1] = 0x64;
this.buffer[2] = 0x6F;
this.buffer[3] = 0x62;
this.buffer[4] = 0x65;
// Identifier: ASCII "Adobe" (highest index first to avoid additional bound checks).
buffer[4] = 0x65;
buffer[0] = 0x41;
buffer[1] = 0x64;
buffer[2] = 0x6F;
buffer[3] = 0x62;
// Version, currently 100.
BinaryPrimitives.WriteInt16BigEndian(this.buffer.AsSpan(5, 2), 100);
BinaryPrimitives.WriteInt16BigEndian(buffer.Slice(5, 2), 100);
// Flags0
BinaryPrimitives.WriteInt16BigEndian(this.buffer.AsSpan(7, 2), 0);
BinaryPrimitives.WriteInt16BigEndian(buffer.Slice(7, 2), 0);
// Flags1
BinaryPrimitives.WriteInt16BigEndian(this.buffer.AsSpan(9, 2), 0);
BinaryPrimitives.WriteInt16BigEndian(buffer.Slice(9, 2), 0);
// Color transform byte
this.buffer[11] = colorTransform;
buffer[11] = colorTransform;
this.outputStream.Write(this.buffer.AsSpan(0, 12));
this.outputStream.Write(buffer.Slice(0, 12));
}
/// <summary>
/// Writes the EXIF profile.
/// </summary>
/// <param name="exifProfile">The exif profile.</param>
private void WriteExifProfile(ExifProfile exifProfile)
/// <param name="buffer">Temporary buffer.</param>
private void WriteExifProfile(ExifProfile exifProfile, Span<byte> buffer)
{
if (exifProfile is null || exifProfile.Values.Count == 0)
{
@ -262,7 +262,7 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
int app1Length = bytesToWrite + 2;
// Write the app marker, EXIF marker, and data
this.WriteApp1Header(app1Length);
this.WriteApp1Header(app1Length, buffer);
this.outputStream.Write(Components.Decoder.ProfileResolver.ExifMarker);
this.outputStream.Write(data, 0, bytesToWrite - exifMarkerLength);
remaining -= bytesToWrite;
@ -273,7 +273,7 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
bytesToWrite = remaining > maxBytesWithExifId ? maxBytesWithExifId : remaining;
app1Length = bytesToWrite + 2 + exifMarkerLength;
this.WriteApp1Header(app1Length);
this.WriteApp1Header(app1Length, buffer);
// Write Exif00 marker
this.outputStream.Write(Components.Decoder.ProfileResolver.ExifMarker);
@ -289,10 +289,11 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
/// Writes the IPTC metadata.
/// </summary>
/// <param name="iptcProfile">The iptc metadata to write.</param>
/// <param name="buffer">Temporary buffer.</param>
/// <exception cref="ImageFormatException">
/// Thrown if the IPTC profile size exceeds the limit of 65533 bytes.
/// </exception>
private void WriteIptcProfile(IptcProfile iptcProfile)
private void WriteIptcProfile(IptcProfile iptcProfile, Span<byte> buffer)
{
const int maxBytes = 65533;
if (iptcProfile is null || !iptcProfile.Values.Any())
@ -316,14 +317,14 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
Components.Decoder.ProfileResolver.AdobeImageResourceBlockMarker.Length +
Components.Decoder.ProfileResolver.AdobeIptcMarker.Length +
2 + 4 + data.Length;
this.WriteAppHeader(app13Length, JpegConstants.Markers.APP13);
this.WriteAppHeader(app13Length, JpegConstants.Markers.APP13, buffer);
this.outputStream.Write(Components.Decoder.ProfileResolver.AdobePhotoshopApp13Marker);
this.outputStream.Write(Components.Decoder.ProfileResolver.AdobeImageResourceBlockMarker);
this.outputStream.Write(Components.Decoder.ProfileResolver.AdobeIptcMarker);
this.outputStream.WriteByte(0); // a empty pascal string (padded to make size even)
this.outputStream.WriteByte(0);
BinaryPrimitives.WriteInt32BigEndian(this.buffer, data.Length);
this.outputStream.Write(this.buffer, 0, 4);
BinaryPrimitives.WriteInt32BigEndian(buffer, data.Length);
this.outputStream.Write(buffer, 0, 4);
this.outputStream.Write(data, 0, data.Length);
}
@ -331,10 +332,11 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
/// Writes the XMP metadata.
/// </summary>
/// <param name="xmpProfile">The XMP metadata to write.</param>
/// <param name="buffer">Temporary buffer.</param>
/// <exception cref="ImageFormatException">
/// Thrown if the XMP profile size exceeds the limit of 65533 bytes.
/// </exception>
private void WriteXmpProfile(XmpProfile xmpProfile)
private void WriteXmpProfile(XmpProfile xmpProfile, Span<byte> buffer)
{
if (xmpProfile is null)
{
@ -367,7 +369,7 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
dataLength -= length;
int app1Length = 2 + Components.Decoder.ProfileResolver.XmpMarker.Length + length;
this.WriteApp1Header(app1Length);
this.WriteApp1Header(app1Length, buffer);
this.outputStream.Write(Components.Decoder.ProfileResolver.XmpMarker);
this.outputStream.Write(data, offset, length);
@ -379,32 +381,35 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
/// Writes the App1 header.
/// </summary>
/// <param name="app1Length">The length of the data the app1 marker contains.</param>
private void WriteApp1Header(int app1Length)
=> this.WriteAppHeader(app1Length, JpegConstants.Markers.APP1);
/// <param name="buffer">Temporary buffer.</param>
private void WriteApp1Header(int app1Length, Span<byte> buffer)
=> this.WriteAppHeader(app1Length, JpegConstants.Markers.APP1, buffer);
/// <summary>
/// Writes a AppX header.
/// </summary>
/// <param name="length">The length of the data the app marker contains.</param>
/// <param name="appMarker">The app marker to write.</param>
private void WriteAppHeader(int length, byte appMarker)
/// <param name="buffer">Temporary buffer.</param>
private void WriteAppHeader(int length, byte appMarker, Span<byte> buffer)
{
this.buffer[0] = JpegConstants.Markers.XFF;
this.buffer[1] = appMarker;
this.buffer[2] = (byte)((length >> 8) & 0xFF);
this.buffer[3] = (byte)(length & 0xFF);
buffer[0] = JpegConstants.Markers.XFF;
buffer[1] = appMarker;
buffer[2] = (byte)((length >> 8) & 0xFF);
buffer[3] = (byte)(length & 0xFF);
this.outputStream.Write(this.buffer, 0, 4);
this.outputStream.Write(buffer, 0, 4);
}
/// <summary>
/// Writes the ICC profile.
/// </summary>
/// <param name="iccProfile">The ICC profile to write.</param>
/// <param name="buffer">Temporary buffer.</param>
/// <exception cref="ImageFormatException">
/// Thrown if any of the ICC profiles size exceeds the limit.
/// </exception>
private void WriteIccProfile(IccProfile iccProfile)
private void WriteIccProfile(IccProfile iccProfile, Span<byte> buffer)
{
if (iccProfile is null)
{
@ -446,30 +451,31 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
dataLength -= length;
this.buffer[0] = JpegConstants.Markers.XFF;
this.buffer[1] = JpegConstants.Markers.APP2; // Application Marker
buffer[0] = JpegConstants.Markers.XFF;
buffer[1] = JpegConstants.Markers.APP2; // Application Marker
int markerLength = length + 16;
this.buffer[2] = (byte)((markerLength >> 8) & 0xFF);
this.buffer[3] = (byte)(markerLength & 0xFF);
this.outputStream.Write(this.buffer, 0, 4);
this.buffer[0] = (byte)'I';
this.buffer[1] = (byte)'C';
this.buffer[2] = (byte)'C';
this.buffer[3] = (byte)'_';
this.buffer[4] = (byte)'P';
this.buffer[5] = (byte)'R';
this.buffer[6] = (byte)'O';
this.buffer[7] = (byte)'F';
this.buffer[8] = (byte)'I';
this.buffer[9] = (byte)'L';
this.buffer[10] = (byte)'E';
this.buffer[11] = 0x00;
this.buffer[12] = (byte)current; // The position within the collection.
this.buffer[13] = (byte)count; // The total number of profiles.
this.outputStream.Write(this.buffer, 0, iccOverheadLength);
buffer[2] = (byte)((markerLength >> 8) & 0xFF);
buffer[3] = (byte)(markerLength & 0xFF);
this.outputStream.Write(buffer, 0, 4);
// We write the highest index first, to have only one bound check.
buffer[13] = (byte)count; // The total number of profiles.
buffer[12] = (byte)current; // The position within the collection.
buffer[11] = 0x00;
buffer[0] = (byte)'I';
buffer[1] = (byte)'C';
buffer[2] = (byte)'C';
buffer[3] = (byte)'_';
buffer[4] = (byte)'P';
buffer[5] = (byte)'R';
buffer[6] = (byte)'O';
buffer[7] = (byte)'F';
buffer[8] = (byte)'I';
buffer[9] = (byte)'L';
buffer[10] = (byte)'E';
this.outputStream.Write(buffer, 0, iccOverheadLength);
this.outputStream.Write(data, offset, length);
current++;
@ -481,7 +487,8 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
/// Writes the metadata profiles to the image.
/// </summary>
/// <param name="metadata">The image metadata.</param>
private void WriteProfiles(ImageMetadata metadata)
/// <param name="buffer">Temporary buffer.</param>
private void WriteProfiles(ImageMetadata metadata, Span<byte> buffer)
{
if (metadata is null)
{
@ -494,10 +501,10 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
// - APP2 ICC
// - APP13 IPTC
metadata.SyncProfiles();
this.WriteExifProfile(metadata.ExifProfile);
this.WriteXmpProfile(metadata.XmpProfile);
this.WriteIccProfile(metadata.IccProfile);
this.WriteIptcProfile(metadata.IptcProfile);
this.WriteExifProfile(metadata.ExifProfile, buffer);
this.WriteXmpProfile(metadata.XmpProfile, buffer);
this.WriteIccProfile(metadata.IccProfile, buffer);
this.WriteIptcProfile(metadata.IptcProfile, buffer);
}
/// <summary>
@ -506,25 +513,26 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
/// <param name="width">The frame width.</param>
/// <param name="height">The frame height.</param>
/// <param name="frame">The frame configuration.</param>
private void WriteStartOfFrame(int width, int height, JpegFrameConfig frame)
/// <param name="buffer">Temporary buffer.</param>
private void WriteStartOfFrame(int width, int height, JpegFrameConfig frame, Span<byte> buffer)
{
JpegComponentConfig[] components = frame.Components;
// Length (high byte, low byte), 8 + components * 3.
int markerlen = 8 + (3 * components.Length);
this.WriteMarkerHeader(JpegConstants.Markers.SOF0, markerlen);
this.buffer[0] = 8; // Data Precision. 8 for now, 12 and 16 bit jpegs not supported
this.buffer[1] = (byte)(height >> 8);
this.buffer[2] = (byte)(height & 0xff); // (2 bytes, Hi-Lo), must be > 0 if DNL not supported
this.buffer[3] = (byte)(width >> 8);
this.buffer[4] = (byte)(width & 0xff); // (2 bytes, Hi-Lo), must be > 0 if DNL not supported
this.buffer[5] = (byte)components.Length;
this.WriteMarkerHeader(JpegConstants.Markers.SOF0, markerlen, buffer);
buffer[5] = (byte)components.Length;
buffer[0] = 8; // Data Precision. 8 for now, 12 and 16 bit jpegs not supported
buffer[1] = (byte)(height >> 8);
buffer[2] = (byte)(height & 0xff); // (2 bytes, Hi-Lo), must be > 0 if DNL not supported
buffer[3] = (byte)(width >> 8);
buffer[4] = (byte)(width & 0xff); // (2 bytes, Hi-Lo), must be > 0 if DNL not supported
// Components data
for (int i = 0; i < components.Length; i++)
{
int i3 = 3 * i;
Span<byte> bufferSpan = this.buffer.AsSpan(i3 + 6, 3);
Span<byte> bufferSpan = buffer.Slice(i3 + 6, 3);
// Quantization table selector
bufferSpan[2] = (byte)components[i].QuantizatioTableIndex;
@ -538,14 +546,15 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
bufferSpan[0] = components[i].Id;
}
this.outputStream.Write(this.buffer, 0, (3 * (components.Length - 1)) + 9);
this.outputStream.Write(buffer, 0, (3 * (components.Length - 1)) + 9);
}
/// <summary>
/// Writes the StartOfScan marker.
/// </summary>
/// <param name="components">The collecction of component configuration items.</param>
private void WriteStartOfScan(Span<JpegComponentConfig> components)
/// <param name="buffer">Temporary buffer.</param>
private void WriteStartOfScan(Span<JpegComponentConfig> components, Span<byte> buffer)
{
// Write the SOS (Start Of Scan) marker "\xff\xda" followed by 12 bytes:
// - the marker length "\x00\x0c",
@ -556,14 +565,14 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
// - the bytes "\x00\x3f\x00". Section B.2.3 of the spec says that for
// sequential DCTs, those bytes (8-bit Ss, 8-bit Se, 4-bit Ah, 4-bit Al)
// should be 0x00, 0x3f, 0x00&lt;&lt;4 | 0x00.
this.buffer[0] = JpegConstants.Markers.XFF;
this.buffer[1] = JpegConstants.Markers.SOS;
buffer[1] = JpegConstants.Markers.SOS;
buffer[0] = JpegConstants.Markers.XFF;
// Length (high byte, low byte), must be 6 + 2 * (number of components in scan)
int sosSize = 6 + (2 * components.Length);
this.buffer[2] = 0x00;
this.buffer[3] = (byte)sosSize;
this.buffer[4] = (byte)components.Length; // Number of components in a scan
buffer[4] = (byte)components.Length; // Number of components in a scan
buffer[3] = (byte)sosSize;
buffer[2] = 0x00;
// Components data
for (int i = 0; i < components.Length; i++)
@ -571,27 +580,28 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
int i2 = 2 * i;
// Id
this.buffer[i2 + 5] = components[i].Id;
buffer[i2 + 5] = components[i].Id;
// Table selectors
int tableSelectors = (components[i].DcTableSelector << 4) | components[i].AcTableSelector;
this.buffer[i2 + 6] = (byte)tableSelectors;
buffer[i2 + 6] = (byte)tableSelectors;
}
this.buffer[sosSize - 1] = 0x00; // Ss - Start of spectral selection.
this.buffer[sosSize] = 0x3f; // Se - End of spectral selection.
this.buffer[sosSize + 1] = 0x00; // Ah + Ah (Successive approximation bit position high + low)
this.outputStream.Write(this.buffer, 0, sosSize + 2);
buffer[sosSize - 1] = 0x00; // Ss - Start of spectral selection.
buffer[sosSize] = 0x3f; // Se - End of spectral selection.
buffer[sosSize + 1] = 0x00; // Ah + Ah (Successive approximation bit position high + low)
this.outputStream.Write(buffer, 0, sosSize + 2);
}
/// <summary>
/// Writes the EndOfImage marker.
/// </summary>
private void WriteEndOfImageMarker()
/// <param name="buffer">Temporary buffer.</param>
private void WriteEndOfImageMarker(Span<byte> buffer)
{
this.buffer[0] = JpegConstants.Markers.XFF;
this.buffer[1] = JpegConstants.Markers.EOI;
this.outputStream.Write(this.buffer, 0, 2);
buffer[1] = JpegConstants.Markers.EOI;
buffer[0] = JpegConstants.Markers.XFF;
this.outputStream.Write(buffer, 0, 2);
}
/// <summary>
@ -602,12 +612,14 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
/// <param name="frameConfig">The frame configuration.</param>
/// <param name="spectralConverter">The spectral converter.</param>
/// <param name="encoder">The scan encoder.</param>
/// <param name="buffer">Temporary buffer.</param>
/// <param name="cancellationToken">The cancellation token.</param>
private void WriteHuffmanScans<TPixel>(
JpegFrame frame,
JpegFrameConfig frameConfig,
SpectralConverter<TPixel> spectralConverter,
HuffmanScanEncoder encoder,
Span<byte> buffer,
CancellationToken cancellationToken)
where TPixel : unmanaged, IPixel<TPixel>
{
@ -615,14 +627,14 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
{
frame.AllocateComponents(fullScan: false);
this.WriteStartOfScan(frameConfig.Components);
this.WriteStartOfScan(frameConfig.Components, buffer);
encoder.EncodeScanBaselineSingleComponent(frame.Components[0], spectralConverter, cancellationToken);
}
else if (frame.Interleaved)
{
frame.AllocateComponents(fullScan: false);
this.WriteStartOfScan(frameConfig.Components);
this.WriteStartOfScan(frameConfig.Components, buffer);
encoder.EncodeScanBaselineInterleaved(frameConfig.EncodingColor, frame, spectralConverter, cancellationToken);
}
else
@ -633,7 +645,7 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
Span<JpegComponentConfig> components = frameConfig.Components;
for (int i = 0; i < frame.Components.Length; i++)
{
this.WriteStartOfScan(components.Slice(i, 1));
this.WriteStartOfScan(components.Slice(i, 1), buffer);
encoder.EncodeScanBaseline(frame.Components[i], cancellationToken);
}
}
@ -644,14 +656,16 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
/// </summary>
/// <param name="marker">The marker to write.</param>
/// <param name="length">The marker length.</param>
private void WriteMarkerHeader(byte marker, int length)
/// <param name="buffer">Temporary buffer.</param>
private void WriteMarkerHeader(byte marker, int length, Span<byte> buffer)
{
// Markers are always prefixed with 0xff.
this.buffer[0] = JpegConstants.Markers.XFF;
this.buffer[1] = marker;
this.buffer[2] = (byte)(length >> 8);
this.buffer[3] = (byte)(length & 0xff);
this.outputStream.Write(this.buffer, 0, 4);
buffer[3] = (byte)(length & 0xff);
buffer[2] = (byte)(length >> 8);
buffer[1] = marker;
buffer[0] = JpegConstants.Markers.XFF;
this.outputStream.Write(buffer, 0, 4);
}
/// <summary>
@ -668,15 +682,16 @@ internal sealed unsafe partial class JpegEncoderCore : IImageEncoderInternals
/// <param name="configs">Quantization tables configs.</param>
/// <param name="optionsQuality">Optional quality value from the options.</param>
/// <param name="metadata">Jpeg metadata instance.</param>
private void WriteDefineQuantizationTables(JpegQuantizationTableConfig[] configs, int? optionsQuality, JpegMetadata metadata)
/// <param name="tmpBuffer">Temporary buffer.</param>
private void WriteDefineQuantizationTables(JpegQuantizationTableConfig[] configs, int? optionsQuality, JpegMetadata metadata, Span<byte> tmpBuffer)
{
int dataLen = configs.Length * (1 + Block8x8.Size);
// Marker + quantization table lengths.
int markerlen = 2 + dataLen;
this.WriteMarkerHeader(JpegConstants.Markers.DQT, markerlen);
this.WriteMarkerHeader(JpegConstants.Markers.DQT, markerlen, tmpBuffer);
byte[] buffer = new byte[dataLen];
Span<byte> buffer = dataLen <= 256 ? stackalloc byte[dataLen] : new byte[dataLen];
int offset = 0;
Block8x8F workspaceBlock = default;

Loading…
Cancel
Save