Browse Source

Merge pull request #3001 from SixLabors/js/fix-2992

v4 : Add Full Metadata Parsing for WEBP Animations
pull/3003/head
James Jackson-South 7 months ago
committed by GitHub
parent
commit
44f5af1354
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      src/ImageSharp/Formats/Png/PngFrameMetadata.cs
  2. 112
      src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs
  3. 57
      src/ImageSharp/Formats/Webp/WebpChunkParsingUtils.cs
  4. 50
      src/ImageSharp/Formats/Webp/WebpDecoderCore.cs
  5. 5
      src/ImageSharp/Formats/Webp/WebpImageInfo.cs
  6. 30
      tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs

2
src/ImageSharp/Formats/Png/PngFrameMetadata.cs

@ -32,7 +32,7 @@ public class PngFrameMetadata : IFormatFrameMetadata<PngFrameMetadata>
/// <summary> /// <summary>
/// Gets or sets the frame delay for animated images. /// Gets or sets the frame delay for animated images.
/// If not 0, when utilized in Png animation, this field specifies the number of hundredths (1/100) of a second to /// If not 0, when utilized in Png animation, this field specifies the number of seconds to
/// wait before continuing with the processing of the Data Stream. /// wait before continuing with the processing of the Data Stream.
/// The clock starts ticking immediately after the graphic is rendered. /// The clock starts ticking immediately after the graphic is rendered.
/// </summary> /// </summary>

112
src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs

@ -32,6 +32,11 @@ internal class WebpAnimationDecoder : IDisposable
/// </summary> /// </summary>
private readonly uint maxFrames; private readonly uint maxFrames;
/// <summary>
/// Whether to skip metadata.
/// </summary>
private readonly bool skipMetadata;
/// <summary> /// <summary>
/// The area to restore. /// The area to restore.
/// </summary> /// </summary>
@ -57,19 +62,97 @@ internal class WebpAnimationDecoder : IDisposable
/// </summary> /// </summary>
private readonly BackgroundColorHandling backgroundColorHandling; private readonly BackgroundColorHandling backgroundColorHandling;
/// <summary>
/// How to handle validation of errors in different segments of encoded image files.
/// </summary>
private readonly SegmentIntegrityHandling segmentIntegrityHandling;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="WebpAnimationDecoder"/> class. /// Initializes a new instance of the <see cref="WebpAnimationDecoder"/> class.
/// </summary> /// </summary>
/// <param name="memoryAllocator">The memory allocator.</param> /// <param name="memoryAllocator">The memory allocator.</param>
/// <param name="configuration">The global configuration.</param> /// <param name="configuration">The global configuration.</param>
/// <param name="maxFrames">The maximum number of frames to decode. Inclusive.</param> /// <param name="maxFrames">The maximum number of frames to decode. Inclusive.</param>
/// <param name="skipMetadata">Whether to skip metadata.</param>
/// <param name="backgroundColorHandling">The flag to decide how to handle the background color in the Animation Chunk.</param> /// <param name="backgroundColorHandling">The flag to decide how to handle the background color in the Animation Chunk.</param>
public WebpAnimationDecoder(MemoryAllocator memoryAllocator, Configuration configuration, uint maxFrames, BackgroundColorHandling backgroundColorHandling) /// <param name="segmentIntegrityHandling">How to handle validation of errors in different segments of encoded image files.</param>
public WebpAnimationDecoder(
MemoryAllocator memoryAllocator,
Configuration configuration,
uint maxFrames,
bool skipMetadata,
BackgroundColorHandling backgroundColorHandling,
SegmentIntegrityHandling segmentIntegrityHandling)
{ {
this.memoryAllocator = memoryAllocator; this.memoryAllocator = memoryAllocator;
this.configuration = configuration; this.configuration = configuration;
this.maxFrames = maxFrames; this.maxFrames = maxFrames;
this.skipMetadata = skipMetadata;
this.backgroundColorHandling = backgroundColorHandling; this.backgroundColorHandling = backgroundColorHandling;
this.segmentIntegrityHandling = segmentIntegrityHandling;
}
/// <summary>
/// Reads the animated webp image information from the specified stream.
/// </summary>
/// <param name="stream">The stream, where the image should be decoded from. Cannot be null.</param>
/// <param name="features">The webp features.</param>
/// <param name="width">The width of the image.</param>
/// <param name="height">The height of the image.</param>
/// <param name="completeDataSize">The size of the image data in bytes.</param>
public ImageInfo Identify(
BufferedReadStream stream,
WebpFeatures features,
uint width,
uint height,
uint completeDataSize)
{
List<ImageFrameMetadata> framesMetadata = [];
this.metadata = new ImageMetadata();
this.webpMetadata = this.metadata.GetWebpMetadata();
this.webpMetadata.RepeatCount = features.AnimationLoopCount;
Color backgroundColor = this.backgroundColorHandling == BackgroundColorHandling.Ignore
? Color.Transparent
: features.AnimationBackgroundColor!.Value;
this.webpMetadata.BackgroundColor = backgroundColor;
Span<byte> buffer = stackalloc byte[4];
uint frameCount = 0;
int remainingBytes = (int)completeDataSize;
while (remainingBytes > 0)
{
WebpChunkType chunkType = WebpChunkParsingUtils.ReadChunkType(stream, buffer);
remainingBytes -= 4;
switch (chunkType)
{
case WebpChunkType.FrameData:
ImageFrameMetadata frameMetadata = new();
uint dataSize = ReadFrameInfo(stream, ref frameMetadata);
framesMetadata.Add(frameMetadata);
remainingBytes -= (int)dataSize;
break;
case WebpChunkType.Xmp:
case WebpChunkType.Exif:
WebpChunkParsingUtils.ParseOptionalChunks(stream, chunkType, this.metadata, this.skipMetadata, this.segmentIntegrityHandling, buffer);
break;
default:
// Specification explicitly states to ignore unknown chunks.
// We do not support writing these chunks at present.
break;
}
if (stream.Position == stream.Length || ++frameCount == this.maxFrames)
{
break;
}
}
return new ImageInfo(new Size((int)width, (int)height), this.metadata, framesMetadata);
} }
/// <summary> /// <summary>
@ -128,10 +211,12 @@ internal class WebpAnimationDecoder : IDisposable
break; break;
case WebpChunkType.Xmp: case WebpChunkType.Xmp:
case WebpChunkType.Exif: case WebpChunkType.Exif:
WebpChunkParsingUtils.ParseOptionalChunks(stream, chunkType, image!.Metadata, false, buffer); WebpChunkParsingUtils.ParseOptionalChunks(stream, chunkType, image!.Metadata, this.skipMetadata, this.segmentIntegrityHandling, buffer);
break; break;
default: default:
WebpThrowHelper.ThrowImageFormatException("Read unexpected webp chunk data");
// Specification explicitly states to ignore unknown chunks.
// We do not support writing these chunks at present.
break; break;
} }
@ -144,6 +229,26 @@ internal class WebpAnimationDecoder : IDisposable
return image!; return image!;
} }
/// <summary>
/// Reads frame information from the specified stream and updates the provided frame metadata.
/// </summary>
/// <param name="stream">The stream from which to read the frame information. Must support reading and seeking.</param>
/// <param name="frameMetadata">A reference to the structure that will be updated with the parsed frame metadata.</param>
/// <returns>The number of bytes read from the stream while parsing the frame information.</returns>
private static uint ReadFrameInfo(BufferedReadStream stream, ref ImageFrameMetadata frameMetadata)
{
WebpFrameData frameData = WebpFrameData.Parse(stream);
SetFrameMetadata(frameMetadata, frameData);
// Size of the frame header chunk.
const int chunkHeaderSize = 16;
uint remaining = frameData.DataSize - chunkHeaderSize;
stream.Skip((int)remaining);
return remaining;
}
/// <summary> /// <summary>
/// Reads an individual webp frame. /// Reads an individual webp frame.
/// </summary> /// </summary>
@ -155,6 +260,7 @@ internal class WebpAnimationDecoder : IDisposable
/// <param name="width">The width of the image.</param> /// <param name="width">The width of the image.</param>
/// <param name="height">The height of the image.</param> /// <param name="height">The height of the image.</param>
/// <param name="backgroundColor">The default background color of the canvas in.</param> /// <param name="backgroundColor">The default background color of the canvas in.</param>
/// <returns>The number of bytes read from the stream while parsing the frame information.</returns>
private uint ReadFrame<TPixel>( private uint ReadFrame<TPixel>(
BufferedReadStream stream, BufferedReadStream stream,
ref Image<TPixel>? image, ref Image<TPixel>? image,

57
src/ImageSharp/Formats/Webp/WebpChunkParsingUtils.cs

@ -2,6 +2,7 @@
// Licensed under the Six Labors Split License. // Licensed under the Six Labors Split License.
using System.Buffers.Binary; using System.Buffers.Binary;
using SixLabors.ImageSharp.Common.Helpers;
using SixLabors.ImageSharp.Formats.Webp.BitReader; using SixLabors.ImageSharp.Formats.Webp.BitReader;
using SixLabors.ImageSharp.Formats.Webp.Lossy; using SixLabors.ImageSharp.Formats.Webp.Lossy;
using SixLabors.ImageSharp.IO; using SixLabors.ImageSharp.IO;
@ -120,6 +121,7 @@ internal static class WebpChunkParsingUtils
return new WebpImageInfo return new WebpImageInfo
{ {
DataSize = dataSize,
Width = width, Width = width,
Height = height, Height = height,
XScale = xScale, XScale = xScale,
@ -178,6 +180,7 @@ internal static class WebpChunkParsingUtils
return new WebpImageInfo return new WebpImageInfo
{ {
DataSize = imageDataSize,
Width = width, Width = width,
Height = height, Height = height,
BitsPerPixel = features.Alpha ? WebpBitsPerPixel.Bit32 : WebpBitsPerPixel.Bit24, BitsPerPixel = features.Alpha ? WebpBitsPerPixel.Bit32 : WebpBitsPerPixel.Bit24,
@ -333,7 +336,13 @@ internal static class WebpChunkParsingUtils
/// If there are more such chunks, readers MAY ignore all except the first one. /// If there are more such chunks, readers MAY ignore all except the first one.
/// Also, a file may possibly contain both 'EXIF' and 'XMP ' chunks. /// Also, a file may possibly contain both 'EXIF' and 'XMP ' chunks.
/// </summary> /// </summary>
public static void ParseOptionalChunks(BufferedReadStream stream, WebpChunkType chunkType, ImageMetadata metadata, bool ignoreMetaData, Span<byte> buffer) public static void ParseOptionalChunks(
BufferedReadStream stream,
WebpChunkType chunkType,
ImageMetadata metadata,
bool ignoreMetaData,
SegmentIntegrityHandling segmentIntegrityHandling,
Span<byte> buffer)
{ {
long streamLength = stream.Length; long streamLength = stream.Length;
while (stream.Position < streamLength) while (stream.Position < streamLength)
@ -353,12 +362,30 @@ internal static class WebpChunkParsingUtils
bytesRead = stream.Read(exifData, 0, (int)chunkLength); bytesRead = stream.Read(exifData, 0, (int)chunkLength);
if (bytesRead != chunkLength) if (bytesRead != chunkLength)
{ {
WebpThrowHelper.ThrowImageFormatException("Could not read enough data for the EXIF profile"); if (segmentIntegrityHandling == SegmentIntegrityHandling.IgnoreNone)
{
WebpThrowHelper.ThrowImageFormatException("Could not read enough data for the EXIF profile");
}
return;
} }
if (metadata.ExifProfile != null) if (metadata.ExifProfile == null)
{ {
metadata.ExifProfile = new ExifProfile(exifData); ExifProfile exifProfile = new(exifData);
// Set the resolution from the metadata.
double horizontalValue = GetExifResolutionValue(exifProfile, ExifTag.XResolution);
double verticalValue = GetExifResolutionValue(exifProfile, ExifTag.YResolution);
if (horizontalValue > 0 && verticalValue > 0)
{
metadata.HorizontalResolution = horizontalValue;
metadata.VerticalResolution = verticalValue;
metadata.ResolutionUnits = UnitConverter.ExifProfileToResolutionUnit(exifProfile);
}
metadata.ExifProfile = exifProfile;
} }
break; break;
@ -367,14 +394,16 @@ internal static class WebpChunkParsingUtils
bytesRead = stream.Read(xmpData, 0, (int)chunkLength); bytesRead = stream.Read(xmpData, 0, (int)chunkLength);
if (bytesRead != chunkLength) if (bytesRead != chunkLength)
{ {
WebpThrowHelper.ThrowImageFormatException("Could not read enough data for the XMP profile"); if (segmentIntegrityHandling == SegmentIntegrityHandling.IgnoreNone)
} {
WebpThrowHelper.ThrowImageFormatException("Could not read enough data for the XMP profile");
}
if (metadata.XmpProfile != null) return;
{
metadata.XmpProfile = new XmpProfile(xmpData);
} }
metadata.XmpProfile ??= new XmpProfile(xmpData);
break; break;
default: default:
stream.Skip((int)chunkLength); stream.Skip((int)chunkLength);
@ -383,6 +412,16 @@ internal static class WebpChunkParsingUtils
} }
} }
private static double GetExifResolutionValue(ExifProfile exifProfile, ExifTag<Rational> tag)
{
if (exifProfile.TryGetValue(tag, out IExifValue<Rational>? resolution))
{
return resolution.Value.ToDouble();
}
return 0;
}
/// <summary> /// <summary>
/// Determines if the chunk type is an optional VP8X chunk. /// Determines if the chunk type is an optional VP8X chunk.
/// </summary> /// </summary>

50
src/ImageSharp/Formats/Webp/WebpDecoderCore.cs

@ -56,6 +56,8 @@ internal sealed class WebpDecoderCore : ImageDecoderCore, IDisposable
/// </summary> /// </summary>
private readonly BackgroundColorHandling backgroundColorHandling; private readonly BackgroundColorHandling backgroundColorHandling;
private readonly SegmentIntegrityHandling segmentIntegrityHandling;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="WebpDecoderCore"/> class. /// Initializes a new instance of the <see cref="WebpDecoderCore"/> class.
/// </summary> /// </summary>
@ -64,6 +66,7 @@ internal sealed class WebpDecoderCore : ImageDecoderCore, IDisposable
: base(options.GeneralOptions) : base(options.GeneralOptions)
{ {
this.backgroundColorHandling = options.BackgroundColorHandling; this.backgroundColorHandling = options.BackgroundColorHandling;
this.segmentIntegrityHandling = options.GeneralOptions.SegmentIntegrityHandling;
this.configuration = options.GeneralOptions.Configuration; this.configuration = options.GeneralOptions.Configuration;
this.skipMetadata = options.GeneralOptions.SkipMetadata; this.skipMetadata = options.GeneralOptions.SkipMetadata;
this.maxFrames = options.GeneralOptions.MaxFrames; this.maxFrames = options.GeneralOptions.MaxFrames;
@ -89,7 +92,10 @@ internal sealed class WebpDecoderCore : ImageDecoderCore, IDisposable
this.memoryAllocator, this.memoryAllocator,
this.configuration, this.configuration,
this.maxFrames, this.maxFrames,
this.backgroundColorHandling); this.skipMetadata,
this.backgroundColorHandling,
this.segmentIntegrityHandling);
return animationDecoder.Decode<TPixel>(stream, this.webImageInfo.Features, this.webImageInfo.Width, this.webImageInfo.Height, fileSize); return animationDecoder.Decode<TPixel>(stream, this.webImageInfo.Features, this.webImageInfo.Width, this.webImageInfo.Height, fileSize);
} }
@ -101,6 +107,7 @@ internal sealed class WebpDecoderCore : ImageDecoderCore, IDisposable
this.webImageInfo.Vp8LBitReader, this.webImageInfo.Vp8LBitReader,
this.memoryAllocator, this.memoryAllocator,
this.configuration); this.configuration);
losslessDecoder.Decode(pixels, image.Width, image.Height); losslessDecoder.Decode(pixels, image.Width, image.Height);
} }
else else
@ -109,6 +116,7 @@ internal sealed class WebpDecoderCore : ImageDecoderCore, IDisposable
this.webImageInfo.Vp8BitReader, this.webImageInfo.Vp8BitReader,
this.memoryAllocator, this.memoryAllocator,
this.configuration); this.configuration);
lossyDecoder.Decode(pixels, image.Width, image.Height, this.webImageInfo, this.alphaData); lossyDecoder.Decode(pixels, image.Width, image.Height, this.webImageInfo, this.alphaData);
} }
@ -131,11 +139,29 @@ internal sealed class WebpDecoderCore : ImageDecoderCore, IDisposable
/// <inheritdoc /> /// <inheritdoc />
protected override ImageInfo Identify(BufferedReadStream stream, CancellationToken cancellationToken) protected override ImageInfo Identify(BufferedReadStream stream, CancellationToken cancellationToken)
{ {
ReadImageHeader(stream, stackalloc byte[4]); uint fileSize = ReadImageHeader(stream, stackalloc byte[4]);
ImageMetadata metadata = new(); ImageMetadata metadata = new();
using (this.webImageInfo = this.ReadVp8Info(stream, metadata, true)) using (this.webImageInfo = this.ReadVp8Info(stream, metadata, true))
{ {
if (this.webImageInfo.Features is { Animation: true })
{
using WebpAnimationDecoder animationDecoder = new(
this.memoryAllocator,
this.configuration,
this.maxFrames,
this.skipMetadata,
this.backgroundColorHandling,
this.segmentIntegrityHandling);
return animationDecoder.Identify(
stream,
this.webImageInfo.Features,
this.webImageInfo.Width,
this.webImageInfo.Height,
fileSize);
}
return new ImageInfo( return new ImageInfo(
new Size((int)this.webImageInfo.Width, (int)this.webImageInfo.Height), new Size((int)this.webImageInfo.Width, (int)this.webImageInfo.Height),
metadata); metadata);
@ -211,6 +237,8 @@ internal sealed class WebpDecoderCore : ImageDecoderCore, IDisposable
} }
else if (WebpChunkParsingUtils.IsOptionalVp8XChunk(chunkType)) else if (WebpChunkParsingUtils.IsOptionalVp8XChunk(chunkType))
{ {
// ANIM chunks appear before EXIF and XMP chunks.
// Return after parsing an ANIM chunk - The animated decoder will handle the rest.
bool isAnimationChunk = this.ParseOptionalExtendedChunks(stream, metadata, chunkType, features, ignoreAlpha, buffer); bool isAnimationChunk = this.ParseOptionalExtendedChunks(stream, metadata, chunkType, features, ignoreAlpha, buffer);
if (isAnimationChunk) if (isAnimationChunk)
{ {
@ -273,7 +301,9 @@ internal sealed class WebpDecoderCore : ImageDecoderCore, IDisposable
this.ReadAlphaData(stream, features, ignoreAlpha, buffer); this.ReadAlphaData(stream, features, ignoreAlpha, buffer);
break; break;
default: default:
WebpThrowHelper.ThrowImageFormatException("Unexpected chunk followed VP8X header");
// Specification explicitly states to ignore unknown chunks.
// We do not support writing these chunks at present.
break; break;
} }
@ -335,7 +365,11 @@ internal sealed class WebpDecoderCore : ImageDecoderCore, IDisposable
int bytesRead = stream.Read(exifData, 0, (int)exifChunkSize); int bytesRead = stream.Read(exifData, 0, (int)exifChunkSize);
if (bytesRead != exifChunkSize) if (bytesRead != exifChunkSize)
{ {
// Ignore invalid chunk. if (this.segmentIntegrityHandling == SegmentIntegrityHandling.IgnoreNone)
{
WebpThrowHelper.ThrowImageFormatException("Could not read enough data for the EXIF profile");
}
return; return;
} }
@ -385,7 +419,11 @@ internal sealed class WebpDecoderCore : ImageDecoderCore, IDisposable
int bytesRead = stream.Read(xmpData, 0, (int)xmpChunkSize); int bytesRead = stream.Read(xmpData, 0, (int)xmpChunkSize);
if (bytesRead != xmpChunkSize) if (bytesRead != xmpChunkSize)
{ {
// Ignore invalid chunk. if (this.segmentIntegrityHandling == SegmentIntegrityHandling.IgnoreNone)
{
WebpThrowHelper.ThrowImageFormatException("Could not read enough data for the XMP profile");
}
return; return;
} }

5
src/ImageSharp/Formats/Webp/WebpImageInfo.cs

@ -8,6 +8,11 @@ namespace SixLabors.ImageSharp.Formats.Webp;
internal class WebpImageInfo : IDisposable internal class WebpImageInfo : IDisposable
{ {
/// <summary>
/// Gets or sets the size of the encoded image data in bytes.
/// </summary>
public uint DataSize { get; set; }
/// <summary> /// <summary>
/// Gets or sets the bitmap width in pixels. /// Gets or sets the bitmap width in pixels.
/// </summary> /// </summary>

30
tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs

@ -314,6 +314,21 @@ public class WebpDecoderTests
Assert.Equal(12, image.Frames.Count); Assert.Equal(12, image.Frames.Count);
} }
[Theory]
[InlineData(Lossless.Animated)]
public void Info_AnimatedLossless_VerifyAllFrames(string imagePath)
{
TestFile testFile = TestFile.Create(imagePath);
using MemoryStream stream = new(testFile.Bytes, false);
ImageInfo image = WebpDecoder.Instance.Identify(DecoderOptions.Default, stream);
WebpMetadata webpMetaData = image.Metadata.GetWebpMetadata();
WebpFrameMetadata frameMetaData = image.FrameMetadataCollection[0].GetWebpMetadata();
Assert.Equal(0, webpMetaData.RepeatCount);
Assert.Equal(150U, frameMetaData.FrameDelay);
Assert.Equal(12, image.FrameCount);
}
[Theory] [Theory]
[WithFile(Lossy.Animated, PixelTypes.Rgba32)] [WithFile(Lossy.Animated, PixelTypes.Rgba32)]
public void Decode_AnimatedLossy_VerifyAllFrames<TPixel>(TestImageProvider<TPixel> provider) public void Decode_AnimatedLossy_VerifyAllFrames<TPixel>(TestImageProvider<TPixel> provider)
@ -331,6 +346,21 @@ public class WebpDecoderTests
Assert.Equal(12, image.Frames.Count); Assert.Equal(12, image.Frames.Count);
} }
[Theory]
[InlineData(Lossy.Animated)]
public void Info_AnimatedLossy_VerifyAllFrames(string imagePath)
{
TestFile testFile = TestFile.Create(imagePath);
using MemoryStream stream = new(testFile.Bytes, false);
ImageInfo image = WebpDecoder.Instance.Identify(DecoderOptions.Default, stream);
WebpMetadata webpMetaData = image.Metadata.GetWebpMetadata();
WebpFrameMetadata frameMetaData = image.FrameMetadataCollection[0].GetWebpMetadata();
Assert.Equal(0, webpMetaData.RepeatCount);
Assert.Equal(150U, frameMetaData.FrameDelay);
Assert.Equal(12, image.FrameCount);
}
[Theory] [Theory]
[WithFile(Lossless.Animated, PixelTypes.Rgba32)] [WithFile(Lossless.Animated, PixelTypes.Rgba32)]
public void Decode_AnimatedLossless_WithFrameDecodingModeFirst_OnlyDecodesOneFrame<TPixel>(TestImageProvider<TPixel> provider) public void Decode_AnimatedLossless_WithFrameDecodingModeFirst_OnlyDecodesOneFrame<TPixel>(TestImageProvider<TPixel> provider)

Loading…
Cancel
Save