From 3c7746ee1fd1327a8e90ce7471dc067dcc96c80c Mon Sep 17 00:00:00 2001
From: Harry Terkelsen <1961493+harryterkelsen@users.noreply.github.com>
Date: Tue, 13 Aug 2024 14:00:58 -0700
Subject: [PATCH] [canvaskit] Add animation detection for GIFs
(flutter/engine#54483)
Detect if a GIF is animated to determine if we need to use Skia to decode it or if we can use
tag decoding.
Fixes https://github.com/flutter/flutter/issues/151911
[C++, Objective-C, Java style guides]: https://github.com/flutter/engine/blob/main/CONTRIBUTING.md#style
---
.../lib/src/engine/image_format_detector.dart | 261 +++++++++++++++++-
.../engine/image_format_detector_test.dart | 17 +-
2 files changed, 267 insertions(+), 11 deletions(-)
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/image_format_detector.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/image_format_detector.dart
index 50af121539..335aabf0eb 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/image_format_detector.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/image_format_detector.dart
@@ -40,6 +40,16 @@ ImageType? detectImageType(Uint8List data) {
return ImageType.animatedWebp;
}
}
+
+ // We conservatively detected an animated GIF. Check if the GIF is actually
+ // animated by reading the bytes.
+ if (format.imageType == ImageType.animatedGif) {
+ if (_GifHeaderReader(data.buffer.asByteData()).isAnimated()) {
+ return ImageType.animatedGif;
+ } else {
+ return ImageType.gif;
+ }
+ }
return format.imageType;
}
@@ -217,8 +227,8 @@ class _WebpHeaderReader {
/// [expectedHeader].
bool _readChunkHeader(String expectedHeader) {
final String chunkFourCC = _readFourCC();
- // Read chunk size.
- _readUint32();
+ // Skip reading chunk size.
+ _position += 4;
return chunkFourCC == expectedHeader;
}
@@ -226,19 +236,13 @@ class _WebpHeaderReader {
bool _readWebpHeader() {
final String riffBytes = _readFourCC();
- // Read file size byte.
- _readUint32();
+ // Skip reading file size bytes.
+ _position += 4;
final String webpBytes = _readFourCC();
return riffBytes == 'RIFF' && webpBytes == 'WEBP';
}
- int _readUint32() {
- final int result = bytes.getUint32(_position, Endian.little);
- _position += 4;
- return result;
- }
-
int _readUint8() {
final int result = bytes.getUint8(_position);
_position += 1;
@@ -258,3 +262,240 @@ class _WebpHeaderReader {
return String.fromCharCodes(chars);
}
}
+
+/// Reads the header of a GIF file to determine if it is animated or not.
+///
+/// See https://www.w3.org/Graphics/GIF/spec-gif89a.txt
+class _GifHeaderReader {
+ _GifHeaderReader(this.bytes);
+
+ final ByteData bytes;
+
+ /// The current position we are reading from in bytes.
+ int _position = 0;
+
+ /// Returns [true] if this GIF is animated.
+ ///
+ /// We say a GIF is animated if it has more than one image frame.
+ bool isAnimated() {
+ final bool isGif = _readGifHeader();
+ if (!isGif) {
+ return false;
+ }
+
+ // Read the logical screen descriptor block.
+
+ // Advance 4 bytes to skip over the screen width and height.
+ _position += 4;
+
+ final int logicalScreenDescriptorFields = _readUint8();
+ const int globalColorTableFlagMask = 1 << 7;
+ final bool hasGlobalColorTable =
+ logicalScreenDescriptorFields & globalColorTableFlagMask != 0;
+
+ // Skip over the background color index and pixel aspect ratio.
+ _position += 2;
+
+ if (hasGlobalColorTable) {
+ // Skip past the global color table.
+ const int globalColorTableSizeMask = 1 << 2 | 1 << 1 | 1;
+ final int globalColorTableSize =
+ logicalScreenDescriptorFields & globalColorTableSizeMask;
+ // This is 3 * 2^(Global Color Table Size + 1).
+ final int globalColorTableSizeInBytes =
+ 3 * (1 << (globalColorTableSize + 1));
+ _position += globalColorTableSizeInBytes;
+ }
+
+ int framesFound = 0;
+ // Read the GIF until we either find 2 frames or reach the end of the GIF.
+ while (true) {
+ final bool isTrailer = _checkForTrailer();
+ if (isTrailer) {
+ return framesFound > 1;
+ }
+
+ // If we haven't reached the end, then the next block must either be a
+ // graphic block or a special-purpose block (comment extension or
+ // application extension).
+ final bool isSpecialPurposeBlock = _checkForSpecialPurposeBlock();
+ if (isSpecialPurposeBlock) {
+ _skipSpecialPurposeBlock();
+ continue;
+ }
+
+ // If the next block isn't a special-purpose block, it must be a graphic
+ // block. Increase the frame count, skip the graphic block, and keep
+ // looking for more.
+ if (framesFound >= 1) {
+ // We've found multiple frames, this is an animated GIF.
+ return true;
+ }
+ _skipGraphicBlock();
+ framesFound++;
+ }
+ }
+
+ /// Reads the GIF header. Returns [false] if this is not a valid GIF header.
+ bool _readGifHeader() {
+ final String signature = _readCharCode();
+ final String version = _readCharCode();
+
+ return signature == 'GIF' && (version == '89a' || version == '87a');
+ }
+
+ /// Returns [true] if the next block is a trailer.
+ bool _checkForTrailer() {
+ final int nextByte = bytes.getUint8(_position);
+ return nextByte == 0x3b;
+ }
+
+ /// Returns [true] if the next block is a Special-Purpose Block (either a
+ /// Comment Extension or an Application Extension).
+ bool _checkForSpecialPurposeBlock() {
+ final int extensionIntroducer = bytes.getUint8(_position);
+ if (extensionIntroducer != 0x21) {
+ return false;
+ }
+
+ final int extensionLabel = bytes.getUint8(_position + 1);
+
+ // The Comment Extension label is 0xFE, the Application Extension Label is
+ // 0xFF.
+ return extensionLabel == 0xfe || extensionLabel == 0xff;
+ }
+
+ /// Skips past the current control block.
+ void _skipSpecialPurposeBlock() {
+ assert(_checkForSpecialPurposeBlock());
+
+ // Skip the extension introducer.
+ _position += 1;
+
+ // Read the extension label to determine if this is a comment block or
+ // application block.
+ final int extensionLabel = _readUint8();
+ if (extensionLabel == 0xfe) {
+ // This is a Comment Extension. Just skip past data sub-blocks.
+ _skipDataBlocks();
+ } else {
+ assert(extensionLabel == 0xff);
+ // This is an Application Extension. Skip past the application identifier
+ // bytes and then skip past the data sub-blocks.
+
+ // Skip the application identifier.
+ _position += 12;
+
+ _skipDataBlocks();
+ }
+ }
+
+ /// Skip past the graphic block.
+ void _skipGraphicBlock() {
+ // Check for the optional Graphic Control Extension.
+ if (_checkForGraphicControlExtension()) {
+ _skipGraphicControlExtension();
+ }
+
+ // Check if the Graphic Block is a Plain Text Extension.
+ if (_checkForPlainTextExtension()) {
+ _skipPlainTextExtension();
+ return;
+ }
+
+ // This is a Table-Based Image block.
+ assert(bytes.getUint8(_position) == 0x2c);
+
+ // Skip to the packed fields to check if there is a local color table.
+ _position += 9;
+
+ final int packedImageDescriptorFields = _readUint8();
+ const int localColorTableFlagMask = 1 << 7;
+ final bool hasLocalColorTable =
+ packedImageDescriptorFields & localColorTableFlagMask != 0;
+ if (hasLocalColorTable) {
+ // Skip past the local color table.
+ const int localColorTableSizeMask = 1 << 2 | 1 << 1 | 1;
+ final int localColorTableSize =
+ packedImageDescriptorFields & localColorTableSizeMask;
+ // This is 3 * 2^(Local Color Table Size + 1).
+ final int localColorTableSizeInBytes =
+ 3 * (1 << (localColorTableSize + 1));
+ _position += localColorTableSizeInBytes;
+ }
+ // Skip LZW minimum code size byte.
+ _position += 1;
+ _skipDataBlocks();
+ }
+
+ /// Returns [true] if the next block is a Graphic Control Extension block.
+ bool _checkForGraphicControlExtension() {
+ final int nextByte = bytes.getUint8(_position);
+ if (nextByte != 0x21) {
+ // This is not an extension block.
+ return false;
+ }
+
+ final int extensionLabel = bytes.getUint8(_position + 1);
+ // The Graphic Control Extension label is 0xF9.
+ return extensionLabel == 0xf9;
+ }
+
+ /// Skip past the Graphic Control Extension block.
+ void _skipGraphicControlExtension() {
+ assert(_checkForGraphicControlExtension());
+ // The Graphic Control Extension block is 8 bytes.
+ _position += 8;
+ }
+
+ /// Check if the next block is a Plain Text Extension block.
+ bool _checkForPlainTextExtension() {
+ final int nextByte = bytes.getUint8(_position);
+ if (nextByte != 0x21) {
+ // This is not an extension block.
+ return false;
+ }
+
+ final int extensionLabel = bytes.getUint8(_position + 1);
+ // The Plain Text Extension label is 0x01.
+ return extensionLabel == 0x01;
+ }
+
+ /// Skip the Plain Text Extension block.
+ void _skipPlainTextExtension() {
+ assert(_checkForPlainTextExtension());
+ // Skip the 15 bytes before the data sub-blocks.
+ _position += 15;
+
+ _skipDataBlocks();
+ }
+
+ /// Skip past any data sub-blocks and the block terminator.
+ void _skipDataBlocks() {
+ while (true) {
+ final int blockSize = _readUint8();
+ if (blockSize == 0) {
+ // This is a block terminator.
+ return;
+ }
+ _position += blockSize;
+ }
+ }
+
+ /// Read a 3 digit character code.
+ String _readCharCode() {
+ final List chars = [
+ bytes.getUint8(_position),
+ bytes.getUint8(_position + 1),
+ bytes.getUint8(_position + 2),
+ ];
+ _position += 3;
+ return String.fromCharCodes(chars);
+ }
+
+ int _readUint8() {
+ final int result = bytes.getUint8(_position);
+ _position += 1;
+ return result;
+ }
+}
diff --git a/engine/src/flutter/lib/web_ui/test/engine/image_format_detector_test.dart b/engine/src/flutter/lib/web_ui/test/engine/image_format_detector_test.dart
index 64a7213fc7..3781dac016 100644
--- a/engine/src/flutter/lib/web_ui/test/engine/image_format_detector_test.dart
+++ b/engine/src/flutter/lib/web_ui/test/engine/image_format_detector_test.dart
@@ -58,12 +58,27 @@ Future testMain() async {
'stoplight.webp',
];
+ // GIF files which are known to be animated.
+ const List animatedGifFiles = [
+ 'alphabetAnim.gif',
+ 'colorTables.gif',
+ 'flightAnim.gif',
+ 'gif-transparent-index.gif',
+ 'randPixelsAnim.gif',
+ 'randPixelsAnim2.gif',
+ 'required.gif',
+ 'test640x479.gif',
+ 'xOffsetTooBig.gif',
+ ];
+
final String testFileExtension =
testFile.substring(testFile.lastIndexOf('.') + 1);
final ImageType? expectedImageType = switch (testFileExtension) {
'jpg' => ImageType.jpeg,
'jpeg' => ImageType.jpeg,
- 'gif' => ImageType.animatedGif,
+ 'gif' => animatedGifFiles.contains(testFile)
+ ? ImageType.animatedGif
+ : ImageType.gif,
'webp' => animatedWebpFiles.contains(testFile)
? ImageType.animatedWebp
: ImageType.webp,