Skip to content

Commit 8e6532a

Browse files
Merge pull request #2713 from SpaceCheetah/FixPngBackport
Backport APNG fix to release/3.1.x
2 parents 326f76d + c8eab78 commit 8e6532a

File tree

18 files changed

+157
-32
lines changed

18 files changed

+157
-32
lines changed

src/ImageSharp/Formats/Png/PngDecoderCore.cs

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,7 @@ public Image<TPixel> Decode<TPixel>(BufferedReadStream stream, CancellationToken
228228
PngThrowHelper.ThrowMissingFrameControl();
229229
}
230230

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

234233
this.currentStream.Position += 4;
235234
this.ReadScanlines(
@@ -240,11 +239,16 @@ public Image<TPixel> Decode<TPixel>(BufferedReadStream stream, CancellationToken
240239
currentFrameControl.Value,
241240
cancellationToken);
242241

243-
previousFrame = currentFrame;
244-
previousFrameControl = currentFrameControl;
242+
// if current frame dispose is restore to previous, then from future frame's perspective, it never happened
243+
if (currentFrameControl.Value.DisposeOperation != PngDisposalMethod.RestoreToPrevious)
244+
{
245+
previousFrame = currentFrame;
246+
previousFrameControl = currentFrameControl;
247+
}
248+
245249
break;
246250
case PngChunkType.Data:
247-
251+
pngMetadata.AnimateRootFrame = currentFrameControl != null;
248252
currentFrameControl ??= new((uint)this.header.Width, (uint)this.header.Height);
249253
if (image is null)
250254
{
@@ -261,9 +265,12 @@ public Image<TPixel> Decode<TPixel>(BufferedReadStream stream, CancellationToken
261265
this.ReadNextDataChunk,
262266
currentFrameControl.Value,
263267
cancellationToken);
268+
if (pngMetadata.AnimateRootFrame)
269+
{
270+
previousFrame = currentFrame;
271+
previousFrameControl = currentFrameControl;
272+
}
264273

265-
previousFrame = currentFrame;
266-
previousFrameControl = currentFrameControl;
267274
break;
268275
case PngChunkType.Palette:
269276
this.palette = chunk.Data.GetSpan().ToArray();
@@ -632,7 +639,7 @@ private void InitializeImage<TPixel>(ImageMetadata metadata, FrameControl frameC
632639
/// <param name="previousFrame">The previous frame.</param>
633640
/// <param name="frame">The created frame</param>
634641
private void InitializeFrame<TPixel>(
635-
FrameControl previousFrameControl,
642+
FrameControl? previousFrameControl,
636643
FrameControl currentFrameControl,
637644
Image<TPixel> image,
638645
ImageFrame<TPixel>? previousFrame,
@@ -645,12 +652,16 @@ private void InitializeFrame<TPixel>(
645652
frame = image.Frames.AddFrame(previousFrame ?? image.Frames.RootFrame);
646653

647654
// If the first `fcTL` chunk uses a `dispose_op` of APNG_DISPOSE_OP_PREVIOUS it should be treated as APNG_DISPOSE_OP_BACKGROUND.
648-
if (previousFrameControl.DisposeOperation == PngDisposalMethod.RestoreToBackground
649-
|| (previousFrame is null && previousFrameControl.DisposeOperation == PngDisposalMethod.RestoreToPrevious))
655+
// So, if restoring to before first frame, clear entire area. Same if first frame (previousFrameControl null).
656+
if (previousFrameControl == null || (previousFrame is null && previousFrameControl.Value.DisposeOperation == PngDisposalMethod.RestoreToPrevious))
657+
{
658+
Buffer2DRegion<TPixel> pixelRegion = frame.PixelBuffer.GetRegion();
659+
pixelRegion.Clear();
660+
}
661+
else if (previousFrameControl.Value.DisposeOperation == PngDisposalMethod.RestoreToBackground)
650662
{
651-
Rectangle restoreArea = previousFrameControl.Bounds;
652-
Rectangle interest = Rectangle.Intersect(frame.Bounds(), restoreArea);
653-
Buffer2DRegion<TPixel> pixelRegion = frame.PixelBuffer.GetRegion(interest);
663+
Rectangle restoreArea = previousFrameControl.Value.Bounds;
664+
Buffer2DRegion<TPixel> pixelRegion = frame.PixelBuffer.GetRegion(restoreArea);
654665
pixelRegion.Clear();
655666
}
656667

src/ImageSharp/Formats/Png/PngEncoderCore.cs

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ public void Encode<TPixel>(Image<TPixel> image, Stream stream, CancellationToken
161161

162162
ImageFrame<TPixel>? clonedFrame = null;
163163
ImageFrame<TPixel> currentFrame = image.Frames.RootFrame;
164+
int currentFrameIndex = 0;
164165

165166
bool clearTransparency = this.encoder.TransparentColorMode is PngTransparentColorMode.Clear;
166167
if (clearTransparency)
@@ -189,29 +190,50 @@ public void Encode<TPixel>(Image<TPixel> image, Stream stream, CancellationToken
189190

190191
if (image.Frames.Count > 1)
191192
{
192-
this.WriteAnimationControlChunk(stream, (uint)image.Frames.Count, pngMetadata.RepeatCount);
193+
this.WriteAnimationControlChunk(stream, (uint)(image.Frames.Count - (pngMetadata.AnimateRootFrame ? 0 : 1)), pngMetadata.RepeatCount);
194+
}
195+
196+
// If the first frame isn't animated, write it as usual and skip it when writing animated frames
197+
if (!pngMetadata.AnimateRootFrame || image.Frames.Count == 1)
198+
{
199+
FrameControl frameControl = new((uint)this.width, (uint)this.height);
200+
this.WriteDataChunks(frameControl, currentFrame.PixelBuffer.GetRegion(), quantized, stream, false);
201+
currentFrameIndex++;
202+
}
193203

194-
// Write the first frame.
204+
if (image.Frames.Count > 1)
205+
{
206+
// Write the first animated frame.
207+
currentFrame = image.Frames[currentFrameIndex];
195208
PngFrameMetadata frameMetadata = GetPngFrameMetadata(currentFrame);
196209
PngDisposalMethod previousDisposal = frameMetadata.DisposalMethod;
197210
FrameControl frameControl = this.WriteFrameControlChunk(stream, frameMetadata, currentFrame.Bounds(), 0);
198-
this.WriteDataChunks(frameControl, currentFrame.PixelBuffer.GetRegion(), quantized, stream, false);
211+
uint sequenceNumber = 1;
212+
if (pngMetadata.AnimateRootFrame)
213+
{
214+
this.WriteDataChunks(frameControl, currentFrame.PixelBuffer.GetRegion(), quantized, stream, false);
215+
}
216+
else
217+
{
218+
sequenceNumber += this.WriteDataChunks(frameControl, currentFrame.PixelBuffer.GetRegion(), quantized, stream, true);
219+
}
220+
221+
currentFrameIndex++;
199222

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

203226
// Write following frames.
204-
uint increment = 0;
205227
ImageFrame<TPixel> previousFrame = image.Frames.RootFrame;
206228

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

210-
for (int i = 1; i < image.Frames.Count; i++)
232+
for (; currentFrameIndex < image.Frames.Count; currentFrameIndex++)
211233
{
212234
ImageFrame<TPixel>? prev = previousDisposal == PngDisposalMethod.RestoreToBackground ? null : previousFrame;
213-
currentFrame = image.Frames[i];
214-
ImageFrame<TPixel>? nextFrame = i < image.Frames.Count - 1 ? image.Frames[i + 1] : null;
235+
currentFrame = image.Frames[currentFrameIndex];
236+
ImageFrame<TPixel>? nextFrame = currentFrameIndex < image.Frames.Count - 1 ? image.Frames[currentFrameIndex + 1] : null;
215237

216238
frameMetadata = GetPngFrameMetadata(currentFrame);
217239
bool blend = frameMetadata.BlendMethod == PngBlendMethod.Over;
@@ -232,22 +254,17 @@ public void Encode<TPixel>(Image<TPixel> image, Stream stream, CancellationToken
232254
}
233255

234256
// Each frame control sequence number must be incremented by the number of frame data chunks that follow.
235-
frameControl = this.WriteFrameControlChunk(stream, frameMetadata, bounds, (uint)i + increment);
257+
frameControl = this.WriteFrameControlChunk(stream, frameMetadata, bounds, sequenceNumber);
236258

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

242264
previousFrame = currentFrame;
243265
previousDisposal = frameMetadata.DisposalMethod;
244266
}
245267
}
246-
else
247-
{
248-
FrameControl frameControl = new((uint)this.width, (uint)this.height);
249-
this.WriteDataChunks(frameControl, currentFrame.PixelBuffer.GetRegion(), quantized, stream, false);
250-
}
251268

252269
this.WriteEndChunk(stream);
253270

src/ImageSharp/Formats/Png/PngMetadata.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ private PngMetadata(PngMetadata other)
2929
this.InterlaceMethod = other.InterlaceMethod;
3030
this.TransparentColor = other.TransparentColor;
3131
this.RepeatCount = other.RepeatCount;
32+
this.AnimateRootFrame = other.AnimateRootFrame;
3233

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

87+
/// <summary>
88+
/// Gets or sets a value indicating whether the root frame is shown as part of the animated sequence
89+
/// </summary>
90+
public bool AnimateRootFrame { get; set; } = true;
91+
8692
/// <inheritdoc/>
8793
public IDeepCloneable DeepClone() => new PngMetadata(this);
8894

src/ImageSharp/Formats/Png/PngScanlineProcessor.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,8 +198,9 @@ public static void ProcessInterlacedPaletteScanline<TPixel>(
198198
ref byte scanlineSpanRef = ref MemoryMarshal.GetReference(scanlineSpan);
199199
ref TPixel rowSpanRef = ref MemoryMarshal.GetReference(rowSpan);
200200
ref Color paletteBase = ref MemoryMarshal.GetReference(palette.Value.Span);
201+
uint offset = pixelOffset + frameControl.XOffset;
201202

202-
for (nuint x = pixelOffset, o = 0; x < frameControl.XMax; x += increment, o++)
203+
for (nuint x = offset, o = 0; x < frameControl.XMax; x += increment, o++)
203204
{
204205
uint index = Unsafe.Add(ref scanlineSpanRef, o);
205206
pixel.FromRgba32(Unsafe.Add(ref paletteBase, index).ToRgba32());

tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,9 @@ public partial class PngDecoderTests
8787
TestImages.Png.DisposeBackgroundRegion,
8888
TestImages.Png.DisposePreviousFirst,
8989
TestImages.Png.DisposeBackgroundBeforeRegion,
90-
TestImages.Png.BlendOverMultiple
90+
TestImages.Png.BlendOverMultiple,
91+
TestImages.Png.FrameOffset,
92+
TestImages.Png.DefaultNotAnimated
9193
};
9294

9395
[Theory]

tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.Chunks.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Buffers.Binary;
55
using SixLabors.ImageSharp.Formats.Png;
6+
using SixLabors.ImageSharp.Formats.Png.Chunks;
67
using SixLabors.ImageSharp.PixelFormats;
78

89
// ReSharper disable InconsistentNaming
@@ -59,6 +60,38 @@ public void EndChunk_IsLast()
5960
}
6061
}
6162

63+
[Theory]
64+
[WithFile(TestImages.Png.DefaultNotAnimated, PixelTypes.Rgba32)]
65+
[WithFile(TestImages.Png.APng, PixelTypes.Rgba32)]
66+
public void AcTL_CorrectlyWritten<TPixel>(TestImageProvider<TPixel> provider)
67+
where TPixel : unmanaged, IPixel<TPixel>
68+
{
69+
using Image<TPixel> image = provider.GetImage(PngDecoder.Instance);
70+
PngMetadata metadata = image.Metadata.GetPngMetadata();
71+
int correctFrameCount = image.Frames.Count - (metadata.AnimateRootFrame ? 0 : 1);
72+
using MemoryStream memStream = new();
73+
image.Save(memStream, PngEncoder);
74+
memStream.Position = 0;
75+
Span<byte> bytesSpan = memStream.ToArray().AsSpan(8); // Skip header.
76+
bool foundAcTl = false;
77+
while (bytesSpan.Length > 0 && !foundAcTl)
78+
{
79+
int length = BinaryPrimitives.ReadInt32BigEndian(bytesSpan[..4]);
80+
PngChunkType type = (PngChunkType)BinaryPrimitives.ReadInt32BigEndian(bytesSpan.Slice(4, 4));
81+
if (type == PngChunkType.AnimationControl)
82+
{
83+
AnimationControl control = AnimationControl.Parse(bytesSpan[8..]);
84+
foundAcTl = true;
85+
Assert.True(control.NumberFrames == correctFrameCount);
86+
Assert.True(control.NumberPlays == metadata.RepeatCount);
87+
}
88+
89+
bytesSpan = bytesSpan[(4 + 4 + length + 4)..];
90+
}
91+
92+
Assert.True(foundAcTl);
93+
}
94+
6295
[Theory]
6396
[InlineData(PngChunkType.Gamma)]
6497
[InlineData(PngChunkType.Chroma)]

tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,8 @@ public void Encode_WithPngTransparentColorBehaviorClear_Works(PngColorType color
447447

448448
[Theory]
449449
[WithFile(TestImages.Png.APng, PixelTypes.Rgba32)]
450+
[WithFile(TestImages.Png.DefaultNotAnimated, PixelTypes.Rgba32)]
451+
[WithFile(TestImages.Png.FrameOffset, PixelTypes.Rgba32)]
450452
public void Encode_APng<TPixel>(TestImageProvider<TPixel> provider)
451453
where TPixel : unmanaged, IPixel<TPixel>
452454
{
@@ -458,15 +460,17 @@ public void Encode_APng<TPixel>(TestImageProvider<TPixel> provider)
458460
image.DebugSave(provider: provider, encoder: PngEncoder, null, false);
459461

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

463-
Assert.Equal(5, image.Frames.Count);
464+
// some loss from original, due to compositing
465+
ImageComparer.TolerantPercentage(0.01f).VerifySimilarity(output, image);
466+
464467
Assert.Equal(image.Frames.Count, output.Frames.Count);
465468

466469
PngMetadata originalMetadata = image.Metadata.GetPngMetadata();
467470
PngMetadata outputMetadata = output.Metadata.GetPngMetadata();
468471

469472
Assert.Equal(originalMetadata.RepeatCount, outputMetadata.RepeatCount);
473+
Assert.Equal(originalMetadata.AnimateRootFrame, outputMetadata.AnimateRootFrame);
470474

471475
for (int i = 0; i < image.Frames.Count; i++)
472476
{

tests/ImageSharp.Tests/Formats/Png/PngMetadataTests.cs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ public void CloneIsDeep()
3232
InterlaceMethod = PngInterlaceMode.Adam7,
3333
Gamma = 2,
3434
TextData = new List<PngTextData> { new PngTextData("name", "value", "foo", "bar") },
35-
RepeatCount = 123
35+
RepeatCount = 123,
36+
AnimateRootFrame = false
3637
};
3738

3839
PngMetadata clone = (PngMetadata)meta.DeepClone();
@@ -44,6 +45,7 @@ public void CloneIsDeep()
4445
Assert.False(meta.TextData.Equals(clone.TextData));
4546
Assert.True(meta.TextData.SequenceEqual(clone.TextData));
4647
Assert.True(meta.RepeatCount == clone.RepeatCount);
48+
Assert.True(meta.AnimateRootFrame == clone.AnimateRootFrame);
4749

4850
clone.BitDepth = PngBitDepth.Bit2;
4951
clone.ColorType = PngColorType.Palette;
@@ -144,6 +146,26 @@ public void Decode_ReadsExifData<TPixel>(TestImageProvider<TPixel> provider)
144146
VerifyExifDataIsPresent(exif);
145147
}
146148

149+
[Theory]
150+
[WithFile(TestImages.Png.DefaultNotAnimated, PixelTypes.Rgba32)]
151+
public void Decode_IdentifiesDefaultFrameNotAnimated<TPixel>(TestImageProvider<TPixel> provider)
152+
where TPixel : unmanaged, IPixel<TPixel>
153+
{
154+
using Image<TPixel> image = provider.GetImage(PngDecoder.Instance);
155+
PngMetadata meta = image.Metadata.GetFormatMetadata(PngFormat.Instance);
156+
Assert.False(meta.AnimateRootFrame);
157+
}
158+
159+
[Theory]
160+
[WithFile(TestImages.Png.APng, PixelTypes.Rgba32)]
161+
public void Decode_IdentifiesDefaultFrameAnimated<TPixel>(TestImageProvider<TPixel> provider)
162+
where TPixel : unmanaged, IPixel<TPixel>
163+
{
164+
using Image<TPixel> image = provider.GetImage(PngDecoder.Instance);
165+
PngMetadata meta = image.Metadata.GetFormatMetadata(PngFormat.Instance);
166+
Assert.True(meta.AnimateRootFrame);
167+
}
168+
147169
[Theory]
148170
[WithFile(TestImages.Png.PngWithMetadata, PixelTypes.Rgba32)]
149171
public void Decode_IgnoresExifData_WhenIgnoreMetadataIsTrue<TPixel>(TestImageProvider<TPixel> provider)

tests/ImageSharp.Tests/TestImages.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ public static class Png
7373
public const string DisposeBackgroundRegion = "Png/animated/15-dispose-background-region.png";
7474
public const string DisposePreviousFirst = "Png/animated/12-dispose-prev-first.png";
7575
public const string BlendOverMultiple = "Png/animated/21-blend-over-multiple.png";
76+
public const string FrameOffset = "Png/animated/frame-offset.png";
77+
public const string DefaultNotAnimated = "Png/animated/default-not-animated.png";
7678
public const string Issue2666 = "Png/issues/Issue_2666.png";
7779

7880
// Filtered test images from http://www.schaik.com/pngsuite/pngsuite_fil_png.html
Lines changed: 3 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)