[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 <img> 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
This commit is contained in:
parent
7a1fd055b8
commit
3c7746ee1f
@ -40,6 +40,16 @@ ImageType? detectImageType(Uint8List data) {
|
|||||||
return ImageType.animatedWebp;
|
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;
|
return format.imageType;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -217,8 +227,8 @@ class _WebpHeaderReader {
|
|||||||
/// [expectedHeader].
|
/// [expectedHeader].
|
||||||
bool _readChunkHeader(String expectedHeader) {
|
bool _readChunkHeader(String expectedHeader) {
|
||||||
final String chunkFourCC = _readFourCC();
|
final String chunkFourCC = _readFourCC();
|
||||||
// Read chunk size.
|
// Skip reading chunk size.
|
||||||
_readUint32();
|
_position += 4;
|
||||||
return chunkFourCC == expectedHeader;
|
return chunkFourCC == expectedHeader;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -226,19 +236,13 @@ class _WebpHeaderReader {
|
|||||||
bool _readWebpHeader() {
|
bool _readWebpHeader() {
|
||||||
final String riffBytes = _readFourCC();
|
final String riffBytes = _readFourCC();
|
||||||
|
|
||||||
// Read file size byte.
|
// Skip reading file size bytes.
|
||||||
_readUint32();
|
_position += 4;
|
||||||
|
|
||||||
final String webpBytes = _readFourCC();
|
final String webpBytes = _readFourCC();
|
||||||
return riffBytes == 'RIFF' && webpBytes == 'WEBP';
|
return riffBytes == 'RIFF' && webpBytes == 'WEBP';
|
||||||
}
|
}
|
||||||
|
|
||||||
int _readUint32() {
|
|
||||||
final int result = bytes.getUint32(_position, Endian.little);
|
|
||||||
_position += 4;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
int _readUint8() {
|
int _readUint8() {
|
||||||
final int result = bytes.getUint8(_position);
|
final int result = bytes.getUint8(_position);
|
||||||
_position += 1;
|
_position += 1;
|
||||||
@ -258,3 +262,240 @@ class _WebpHeaderReader {
|
|||||||
return String.fromCharCodes(chars);
|
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<int> chars = <int>[
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -58,12 +58,27 @@ Future<void> testMain() async {
|
|||||||
'stoplight.webp',
|
'stoplight.webp',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// GIF files which are known to be animated.
|
||||||
|
const List<String> animatedGifFiles = <String>[
|
||||||
|
'alphabetAnim.gif',
|
||||||
|
'colorTables.gif',
|
||||||
|
'flightAnim.gif',
|
||||||
|
'gif-transparent-index.gif',
|
||||||
|
'randPixelsAnim.gif',
|
||||||
|
'randPixelsAnim2.gif',
|
||||||
|
'required.gif',
|
||||||
|
'test640x479.gif',
|
||||||
|
'xOffsetTooBig.gif',
|
||||||
|
];
|
||||||
|
|
||||||
final String testFileExtension =
|
final String testFileExtension =
|
||||||
testFile.substring(testFile.lastIndexOf('.') + 1);
|
testFile.substring(testFile.lastIndexOf('.') + 1);
|
||||||
final ImageType? expectedImageType = switch (testFileExtension) {
|
final ImageType? expectedImageType = switch (testFileExtension) {
|
||||||
'jpg' => ImageType.jpeg,
|
'jpg' => ImageType.jpeg,
|
||||||
'jpeg' => ImageType.jpeg,
|
'jpeg' => ImageType.jpeg,
|
||||||
'gif' => ImageType.animatedGif,
|
'gif' => animatedGifFiles.contains(testFile)
|
||||||
|
? ImageType.animatedGif
|
||||||
|
: ImageType.gif,
|
||||||
'webp' => animatedWebpFiles.contains(testFile)
|
'webp' => animatedWebpFiles.contains(testFile)
|
||||||
? ImageType.animatedWebp
|
? ImageType.animatedWebp
|
||||||
: ImageType.webp,
|
: ImageType.webp,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user