Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix animated png handling (issue #2708) #2710

Merged
merged 14 commits into from
Apr 3, 2024
Merged
41 changes: 24 additions & 17 deletions src/ImageSharp/Formats/Png/PngDecoderCore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,7 @@ public Image<TPixel> Decode<TPixel>(BufferedReadStream stream, CancellationToken
PngThrowHelper.ThrowMissingFrameControl();
}

previousFrameControl ??= new((uint)this.header.Width, (uint)this.header.Height);
this.InitializeFrame(previousFrameControl.Value, currentFrameControl.Value, image, previousFrame, out currentFrame);
this.InitializeFrame(previousFrameControl, currentFrameControl.Value, image, previousFrame, out currentFrame);

this.currentStream.Position += 4;
this.ReadScanlines(
Expand All @@ -246,11 +245,16 @@ public Image<TPixel> Decode<TPixel>(BufferedReadStream stream, CancellationToken
currentFrameControl.Value,
cancellationToken);

previousFrame = currentFrame;
previousFrameControl = currentFrameControl;
// if current frame dispose is restore to previous, then from future frame's perspective, it never happened
if (currentFrameControl.Value.DisposeOperation != PngDisposalMethod.RestoreToPrevious)
JimBobSquarePants marked this conversation as resolved.
Show resolved Hide resolved
{
previousFrame = currentFrame;
previousFrameControl = currentFrameControl;
}

break;
case PngChunkType.Data:

pngMetadata.DefaultImageAnimated = currentFrameControl != null;
currentFrameControl ??= new((uint)this.header.Width, (uint)this.header.Height);
if (image is null)
{
Expand All @@ -267,9 +271,12 @@ public Image<TPixel> Decode<TPixel>(BufferedReadStream stream, CancellationToken
this.ReadNextDataChunk,
currentFrameControl.Value,
cancellationToken);
if (pngMetadata.DefaultImageAnimated)
{
previousFrame = currentFrame;
previousFrameControl = currentFrameControl;
}

previousFrame = currentFrame;
previousFrameControl = currentFrameControl;
break;
case PngChunkType.Palette:
this.palette = chunk.Data.GetSpan().ToArray();
Expand Down Expand Up @@ -638,25 +645,25 @@ private void InitializeImage<TPixel>(ImageMetadata metadata, FrameControl frameC
/// <param name="previousFrame">The previous frame.</param>
/// <param name="frame">The created frame</param>
private void InitializeFrame<TPixel>(
FrameControl previousFrameControl,
FrameControl? previousFrameControl,
FrameControl currentFrameControl,
Image<TPixel> image,
ImageFrame<TPixel>? previousFrame,
out ImageFrame<TPixel> frame)
where TPixel : unmanaged, IPixel<TPixel>
{
// We create a clone of the previous frame and add it.
// We will overpaint the difference of pixels on the current frame to create a complete image.
// This ensures that we have enough pixel data to process without distortion. #2450
frame = image.Frames.AddFrame(previousFrame ?? image.Frames.RootFrame);

// If the first `fcTL` chunk uses a `dispose_op` of APNG_DISPOSE_OP_PREVIOUS it should be treated as APNG_DISPOSE_OP_BACKGROUND.
JimBobSquarePants marked this conversation as resolved.
Show resolved Hide resolved
if (previousFrameControl.DisposeOperation == PngDisposalMethod.RestoreToBackground
|| (previousFrame is null && previousFrameControl.DisposeOperation == PngDisposalMethod.RestoreToPrevious))
// If restoring to before first frame, restore to background. Same if first frame (previousFrameControl null).
if (previousFrameControl == null || (previousFrame is null && previousFrameControl.Value.DisposeOperation == PngDisposalMethod.RestoreToPrevious))
{
Buffer2DRegion<TPixel> pixelRegion = frame.PixelBuffer.GetRegion();
pixelRegion.Clear();
}
else if (previousFrameControl.Value.DisposeOperation == PngDisposalMethod.RestoreToBackground)
{
Rectangle restoreArea = previousFrameControl.Bounds;
Rectangle interest = Rectangle.Intersect(frame.Bounds(), restoreArea);
Buffer2DRegion<TPixel> pixelRegion = frame.PixelBuffer.GetRegion(interest);
Rectangle restoreArea = previousFrameControl.Value.Bounds;
Buffer2DRegion<TPixel> pixelRegion = frame.PixelBuffer.GetRegion(restoreArea);
pixelRegion.Clear();
}

Expand Down
45 changes: 31 additions & 14 deletions src/ImageSharp/Formats/Png/PngEncoderCore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ public void Encode<TPixel>(Image<TPixel> image, Stream stream, CancellationToken

ImageFrame<TPixel>? clonedFrame = null;
ImageFrame<TPixel> currentFrame = image.Frames.RootFrame;
int currentFrameIndex = 0;

bool clearTransparency = this.encoder.TransparentColorMode is PngTransparentColorMode.Clear;
if (clearTransparency)
Expand Down Expand Up @@ -195,29 +196,50 @@ public void Encode<TPixel>(Image<TPixel> image, Stream stream, CancellationToken

if (image.Frames.Count > 1)
{
this.WriteAnimationControlChunk(stream, (uint)image.Frames.Count, pngMetadata.RepeatCount);
this.WriteAnimationControlChunk(stream, (uint)(image.Frames.Count - (pngMetadata.DefaultImageAnimated ? 0 : 1)), pngMetadata.RepeatCount);
}

// If the first frame isn't animated, write it as usual and skip it when writing animated frames
if (!pngMetadata.DefaultImageAnimated || image.Frames.Count == 1)
{
FrameControl frameControl = new((uint)this.width, (uint)this.height);
this.WriteDataChunks(frameControl, currentFrame.PixelBuffer.GetRegion(), quantized, stream, false);
currentFrameIndex++;
}

// Write the first frame.
if (image.Frames.Count > 1)
{
// Write the first animated frame.
currentFrame = image.Frames[currentFrameIndex];
PngFrameMetadata frameMetadata = GetPngFrameMetadata(currentFrame);
PngDisposalMethod previousDisposal = frameMetadata.DisposalMethod;
FrameControl frameControl = this.WriteFrameControlChunk(stream, frameMetadata, currentFrame.Bounds(), 0);
this.WriteDataChunks(frameControl, currentFrame.PixelBuffer.GetRegion(), quantized, stream, false);
uint sequenceNumber = 1;
if (pngMetadata.DefaultImageAnimated)
{
this.WriteDataChunks(frameControl, currentFrame.PixelBuffer.GetRegion(), quantized, stream, false);
}
else
{
sequenceNumber += this.WriteDataChunks(frameControl, currentFrame.PixelBuffer.GetRegion(), quantized, stream, true);
}

currentFrameIndex++;

// Capture the global palette for reuse on subsequent frames.
ReadOnlyMemory<TPixel>? previousPalette = quantized?.Palette.ToArray();

// Write following frames.
uint increment = 0;
ImageFrame<TPixel> previousFrame = image.Frames.RootFrame;

// This frame is reused to store de-duplicated pixel buffers.
using ImageFrame<TPixel> encodingFrame = new(image.Configuration, previousFrame.Size());

for (int i = 1; i < image.Frames.Count; i++)
for (; currentFrameIndex < image.Frames.Count; currentFrameIndex++)
{
ImageFrame<TPixel>? prev = previousDisposal == PngDisposalMethod.RestoreToBackground ? null : previousFrame;
currentFrame = image.Frames[i];
ImageFrame<TPixel>? nextFrame = i < image.Frames.Count - 1 ? image.Frames[i + 1] : null;
currentFrame = image.Frames[currentFrameIndex];
ImageFrame<TPixel>? nextFrame = currentFrameIndex < image.Frames.Count - 1 ? image.Frames[currentFrameIndex + 1] : null;

frameMetadata = GetPngFrameMetadata(currentFrame);
bool blend = frameMetadata.BlendMethod == PngBlendMethod.Over;
Expand All @@ -238,22 +260,17 @@ public void Encode<TPixel>(Image<TPixel> image, Stream stream, CancellationToken
}

// Each frame control sequence number must be incremented by the number of frame data chunks that follow.
frameControl = this.WriteFrameControlChunk(stream, frameMetadata, bounds, (uint)i + increment);
JimBobSquarePants marked this conversation as resolved.
Show resolved Hide resolved
frameControl = this.WriteFrameControlChunk(stream, frameMetadata, bounds, sequenceNumber);

// Dispose of previous quantized frame and reassign.
quantized?.Dispose();
quantized = this.CreateQuantizedImageAndUpdateBitDepth(pngMetadata, encodingFrame, bounds, previousPalette);
increment += this.WriteDataChunks(frameControl, encodingFrame.PixelBuffer.GetRegion(bounds), quantized, stream, true);
sequenceNumber += this.WriteDataChunks(frameControl, encodingFrame.PixelBuffer.GetRegion(bounds), quantized, stream, true) + 1;

previousFrame = currentFrame;
previousDisposal = frameMetadata.DisposalMethod;
}
}
else
{
FrameControl frameControl = new((uint)this.width, (uint)this.height);
this.WriteDataChunks(frameControl, currentFrame.PixelBuffer.GetRegion(), quantized, stream, false);
}

this.WriteEndChunk(stream);

Expand Down
6 changes: 6 additions & 0 deletions src/ImageSharp/Formats/Png/PngMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ private PngMetadata(PngMetadata other)
this.InterlaceMethod = other.InterlaceMethod;
this.TransparentColor = other.TransparentColor;
this.RepeatCount = other.RepeatCount;
this.DefaultImageAnimated = other.DefaultImageAnimated;

if (other.ColorTable?.Length > 0)
{
Expand Down Expand Up @@ -83,6 +84,11 @@ private PngMetadata(PngMetadata other)
/// </summary>
public uint RepeatCount { get; set; } = 1;

/// <summary>
/// Gets or sets a value indicating whether the default image is shown as part of the animated sequence
/// </summary>
public bool DefaultImageAnimated { get; set; } = true;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's call this AnimateRootFrame. That matches ImageFrameCollection.RootFrame naming convention.


/// <inheritdoc/>
public IDeepCloneable DeepClone() => new PngMetadata(this);

Expand Down
3 changes: 2 additions & 1 deletion src/ImageSharp/Formats/Png/PngScanlineProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,9 @@ public static void ProcessInterlacedPaletteScanline<TPixel>(
ref byte scanlineSpanRef = ref MemoryMarshal.GetReference(scanlineSpan);
ref TPixel rowSpanRef = ref MemoryMarshal.GetReference(rowSpan);
ref Color paletteBase = ref MemoryMarshal.GetReference(palette.Value.Span);
uint offset = pixelOffset + frameControl.XOffset;

for (nuint x = pixelOffset, o = 0; x < frameControl.XMax; x += increment, o++)
for (nuint x = offset, o = 0; x < frameControl.XMax; x += increment, o++)
{
uint index = Unsafe.Add(ref scanlineSpanRef, o);
Unsafe.Add(ref rowSpanRef, x) = TPixel.FromRgba32(Unsafe.Add(ref paletteBase, index).ToPixel<Rgba32>());
Expand Down
4 changes: 3 additions & 1 deletion tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ public partial class PngDecoderTests
TestImages.Png.DisposeBackgroundRegion,
TestImages.Png.DisposePreviousFirst,
TestImages.Png.DisposeBackgroundBeforeRegion,
TestImages.Png.BlendOverMultiple
TestImages.Png.BlendOverMultiple,
TestImages.Png.FrameOffset,
TestImages.Png.DefaultNotAnimated
};

[Theory]
Expand Down
33 changes: 33 additions & 0 deletions tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.Chunks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Buffers.Binary;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.Formats.Png.Chunks;
using SixLabors.ImageSharp.PixelFormats;

// ReSharper disable InconsistentNaming
Expand Down Expand Up @@ -59,6 +60,38 @@ public void EndChunk_IsLast()
}
}

[Theory]
[WithFile(TestImages.Png.DefaultNotAnimated, PixelTypes.Rgba32)]
[WithFile(TestImages.Png.APng, PixelTypes.Rgba32)]
public void AcTL_CorrectlyWritten<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
{
using Image<TPixel> image = provider.GetImage(PngDecoder.Instance);
PngMetadata metadata = image.Metadata.GetPngMetadata();
int correctFrameCount = image.Frames.Count - (metadata.DefaultImageAnimated ? 0 : 1);
using MemoryStream memStream = new();
image.Save(memStream, PngEncoder);
memStream.Position = 0;
Span<byte> bytesSpan = memStream.ToArray().AsSpan(8); // Skip header.
bool foundAcTl = false;
while (bytesSpan.Length > 0 && !foundAcTl)
{
int length = BinaryPrimitives.ReadInt32BigEndian(bytesSpan[..4]);
PngChunkType type = (PngChunkType)BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(4, 4));
if (type == PngChunkType.AnimationControl)
{
AnimationControl control = AnimationControl.Parse(bytesSpan[8..]);
foundAcTl = true;
Assert.True(control.NumberFrames == correctFrameCount);
Assert.True(control.NumberPlays == metadata.RepeatCount);
}

bytesSpan = bytesSpan[(4 + 4 + length + 4)..];
}

Assert.True(foundAcTl);
}

[Theory]
[InlineData(PngChunkType.Gamma)]
[InlineData(PngChunkType.Chroma)]
Expand Down
23 changes: 23 additions & 0 deletions tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,29 @@ public void Encode_AnimatedFormatTransform_FromWebp<TPixel>(TestImageProvider<TP
}
}

[Theory]
[WithFile(TestImages.Png.DefaultNotAnimated, PixelTypes.Rgba32)]
public void Encode_DefaultNotAnimated<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
{
using Image<TPixel> image = provider.GetImage(PngDecoder.Instance);
using MemoryStream memStream = new();
image.Save(memStream, PngEncoder);
memStream.Position = 0;

image.DebugSave(provider: provider, encoder: PngEncoder, null, false);

using Image<Rgba32> output = Image.Load<Rgba32>(memStream);
ImageComparer.Exact.VerifySimilarity(output, image);

Assert.Equal(2, image.Frames.Count);
Assert.Equal(image.Frames.Count, output.Frames.Count);

PngMetadata originalMetadata = image.Metadata.GetPngMetadata();
PngMetadata outputMetadata = output.Metadata.GetPngMetadata();
Assert.Equal(originalMetadata.DefaultImageAnimated, outputMetadata.DefaultImageAnimated);
}

[Theory]
[MemberData(nameof(PngTrnsFiles))]
public void Encode_PreserveTrns(string imagePath, PngBitDepth pngBitDepth, PngColorType pngColorType)
Expand Down
24 changes: 23 additions & 1 deletion tests/ImageSharp.Tests/Formats/Png/PngMetadataTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ public void CloneIsDeep()
InterlaceMethod = PngInterlaceMode.Adam7,
Gamma = 2,
TextData = new List<PngTextData> { new PngTextData("name", "value", "foo", "bar") },
RepeatCount = 123
RepeatCount = 123,
DefaultImageAnimated = false
};

PngMetadata clone = (PngMetadata)meta.DeepClone();
Expand All @@ -44,6 +45,7 @@ public void CloneIsDeep()
Assert.False(meta.TextData.Equals(clone.TextData));
Assert.True(meta.TextData.SequenceEqual(clone.TextData));
Assert.True(meta.RepeatCount == clone.RepeatCount);
Assert.True(meta.DefaultImageAnimated == clone.DefaultImageAnimated);

clone.BitDepth = PngBitDepth.Bit2;
clone.ColorType = PngColorType.Palette;
Expand Down Expand Up @@ -144,6 +146,26 @@ public void Decode_ReadsExifData<TPixel>(TestImageProvider<TPixel> provider)
VerifyExifDataIsPresent(exif);
}

[Theory]
[WithFile(TestImages.Png.DefaultNotAnimated, PixelTypes.Rgba32)]
public void Decode_IdentifiesDefaultFrameNotAnimated<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
{
using Image<TPixel> image = provider.GetImage(PngDecoder.Instance);
PngMetadata meta = image.Metadata.GetFormatMetadata(PngFormat.Instance);
Assert.False(meta.DefaultImageAnimated);
}

[Theory]
[WithFile(TestImages.Png.APng, PixelTypes.Rgba32)]
public void Decode_IdentifiesDefaultFrameAnimated<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
{
using Image<TPixel> image = provider.GetImage(PngDecoder.Instance);
PngMetadata meta = image.Metadata.GetFormatMetadata(PngFormat.Instance);
Assert.True(meta.DefaultImageAnimated);
}

[Theory]
[WithFile(TestImages.Png.PngWithMetadata, PixelTypes.Rgba32)]
public void Decode_IgnoresExifData_WhenIgnoreMetadataIsTrue<TPixel>(TestImageProvider<TPixel> provider)
Expand Down
2 changes: 2 additions & 0 deletions tests/ImageSharp.Tests/TestImages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ public static class Png
public const string DisposeBackgroundRegion = "Png/animated/15-dispose-background-region.png";
public const string DisposePreviousFirst = "Png/animated/12-dispose-prev-first.png";
public const string BlendOverMultiple = "Png/animated/21-blend-over-multiple.png";
public const string FrameOffset = "Png/animated/frame-offset.png";
public const string DefaultNotAnimated = "Png/animated/default-not-animated.png";
public const string Issue2666 = "Png/issues/Issue_2666.png";

// Filtered test images from http://www.schaik.com/pngsuite/pngsuite_fil_png.html
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions tests/Images/Input/Png/animated/default-not-animated.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions tests/Images/Input/Png/animated/frame-offset.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading