Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit 2fcb51a

Browse files
[canvaskit] Add animation detection for GIFs (#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 flutter/flutter#151911 [C++, Objective-C, Java style guides]: https://github.com/flutter/engine/blob/main/CONTRIBUTING.md#style
1 parent 9cec8c2 commit 2fcb51a

File tree

2 files changed

+267
-11
lines changed

2 files changed

+267
-11
lines changed

lib/web_ui/lib/src/engine/image_format_detector.dart

Lines changed: 251 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ ImageType? detectImageType(Uint8List data) {
4040
return ImageType.animatedWebp;
4141
}
4242
}
43+
44+
// We conservatively detected an animated GIF. Check if the GIF is actually
45+
// animated by reading the bytes.
46+
if (format.imageType == ImageType.animatedGif) {
47+
if (_GifHeaderReader(data.buffer.asByteData()).isAnimated()) {
48+
return ImageType.animatedGif;
49+
} else {
50+
return ImageType.gif;
51+
}
52+
}
4353
return format.imageType;
4454
}
4555

@@ -217,28 +227,22 @@ class _WebpHeaderReader {
217227
/// [expectedHeader].
218228
bool _readChunkHeader(String expectedHeader) {
219229
final String chunkFourCC = _readFourCC();
220-
// Read chunk size.
221-
_readUint32();
230+
// Skip reading chunk size.
231+
_position += 4;
222232
return chunkFourCC == expectedHeader;
223233
}
224234

225235
/// Reads the WebP header. Returns [false] if this is not a valid WebP header.
226236
bool _readWebpHeader() {
227237
final String riffBytes = _readFourCC();
228238

229-
// Read file size byte.
230-
_readUint32();
239+
// Skip reading file size bytes.
240+
_position += 4;
231241

232242
final String webpBytes = _readFourCC();
233243
return riffBytes == 'RIFF' && webpBytes == 'WEBP';
234244
}
235245

236-
int _readUint32() {
237-
final int result = bytes.getUint32(_position, Endian.little);
238-
_position += 4;
239-
return result;
240-
}
241-
242246
int _readUint8() {
243247
final int result = bytes.getUint8(_position);
244248
_position += 1;
@@ -258,3 +262,240 @@ class _WebpHeaderReader {
258262
return String.fromCharCodes(chars);
259263
}
260264
}
265+
266+
/// Reads the header of a GIF file to determine if it is animated or not.
267+
///
268+
/// See https://www.w3.org/Graphics/GIF/spec-gif89a.txt
269+
class _GifHeaderReader {
270+
_GifHeaderReader(this.bytes);
271+
272+
final ByteData bytes;
273+
274+
/// The current position we are reading from in bytes.
275+
int _position = 0;
276+
277+
/// Returns [true] if this GIF is animated.
278+
///
279+
/// We say a GIF is animated if it has more than one image frame.
280+
bool isAnimated() {
281+
final bool isGif = _readGifHeader();
282+
if (!isGif) {
283+
return false;
284+
}
285+
286+
// Read the logical screen descriptor block.
287+
288+
// Advance 4 bytes to skip over the screen width and height.
289+
_position += 4;
290+
291+
final int logicalScreenDescriptorFields = _readUint8();
292+
const int globalColorTableFlagMask = 1 << 7;
293+
final bool hasGlobalColorTable =
294+
logicalScreenDescriptorFields & globalColorTableFlagMask != 0;
295+
296+
// Skip over the background color index and pixel aspect ratio.
297+
_position += 2;
298+
299+
if (hasGlobalColorTable) {
300+
// Skip past the global color table.
301+
const int globalColorTableSizeMask = 1 << 2 | 1 << 1 | 1;
302+
final int globalColorTableSize =
303+
logicalScreenDescriptorFields & globalColorTableSizeMask;
304+
// This is 3 * 2^(Global Color Table Size + 1).
305+
final int globalColorTableSizeInBytes =
306+
3 * (1 << (globalColorTableSize + 1));
307+
_position += globalColorTableSizeInBytes;
308+
}
309+
310+
int framesFound = 0;
311+
// Read the GIF until we either find 2 frames or reach the end of the GIF.
312+
while (true) {
313+
final bool isTrailer = _checkForTrailer();
314+
if (isTrailer) {
315+
return framesFound > 1;
316+
}
317+
318+
// If we haven't reached the end, then the next block must either be a
319+
// graphic block or a special-purpose block (comment extension or
320+
// application extension).
321+
final bool isSpecialPurposeBlock = _checkForSpecialPurposeBlock();
322+
if (isSpecialPurposeBlock) {
323+
_skipSpecialPurposeBlock();
324+
continue;
325+
}
326+
327+
// If the next block isn't a special-purpose block, it must be a graphic
328+
// block. Increase the frame count, skip the graphic block, and keep
329+
// looking for more.
330+
if (framesFound >= 1) {
331+
// We've found multiple frames, this is an animated GIF.
332+
return true;
333+
}
334+
_skipGraphicBlock();
335+
framesFound++;
336+
}
337+
}
338+
339+
/// Reads the GIF header. Returns [false] if this is not a valid GIF header.
340+
bool _readGifHeader() {
341+
final String signature = _readCharCode();
342+
final String version = _readCharCode();
343+
344+
return signature == 'GIF' && (version == '89a' || version == '87a');
345+
}
346+
347+
/// Returns [true] if the next block is a trailer.
348+
bool _checkForTrailer() {
349+
final int nextByte = bytes.getUint8(_position);
350+
return nextByte == 0x3b;
351+
}
352+
353+
/// Returns [true] if the next block is a Special-Purpose Block (either a
354+
/// Comment Extension or an Application Extension).
355+
bool _checkForSpecialPurposeBlock() {
356+
final int extensionIntroducer = bytes.getUint8(_position);
357+
if (extensionIntroducer != 0x21) {
358+
return false;
359+
}
360+
361+
final int extensionLabel = bytes.getUint8(_position + 1);
362+
363+
// The Comment Extension label is 0xFE, the Application Extension Label is
364+
// 0xFF.
365+
return extensionLabel == 0xfe || extensionLabel == 0xff;
366+
}
367+
368+
/// Skips past the current control block.
369+
void _skipSpecialPurposeBlock() {
370+
assert(_checkForSpecialPurposeBlock());
371+
372+
// Skip the extension introducer.
373+
_position += 1;
374+
375+
// Read the extension label to determine if this is a comment block or
376+
// application block.
377+
final int extensionLabel = _readUint8();
378+
if (extensionLabel == 0xfe) {
379+
// This is a Comment Extension. Just skip past data sub-blocks.
380+
_skipDataBlocks();
381+
} else {
382+
assert(extensionLabel == 0xff);
383+
// This is an Application Extension. Skip past the application identifier
384+
// bytes and then skip past the data sub-blocks.
385+
386+
// Skip the application identifier.
387+
_position += 12;
388+
389+
_skipDataBlocks();
390+
}
391+
}
392+
393+
/// Skip past the graphic block.
394+
void _skipGraphicBlock() {
395+
// Check for the optional Graphic Control Extension.
396+
if (_checkForGraphicControlExtension()) {
397+
_skipGraphicControlExtension();
398+
}
399+
400+
// Check if the Graphic Block is a Plain Text Extension.
401+
if (_checkForPlainTextExtension()) {
402+
_skipPlainTextExtension();
403+
return;
404+
}
405+
406+
// This is a Table-Based Image block.
407+
assert(bytes.getUint8(_position) == 0x2c);
408+
409+
// Skip to the packed fields to check if there is a local color table.
410+
_position += 9;
411+
412+
final int packedImageDescriptorFields = _readUint8();
413+
const int localColorTableFlagMask = 1 << 7;
414+
final bool hasLocalColorTable =
415+
packedImageDescriptorFields & localColorTableFlagMask != 0;
416+
if (hasLocalColorTable) {
417+
// Skip past the local color table.
418+
const int localColorTableSizeMask = 1 << 2 | 1 << 1 | 1;
419+
final int localColorTableSize =
420+
packedImageDescriptorFields & localColorTableSizeMask;
421+
// This is 3 * 2^(Local Color Table Size + 1).
422+
final int localColorTableSizeInBytes =
423+
3 * (1 << (localColorTableSize + 1));
424+
_position += localColorTableSizeInBytes;
425+
}
426+
// Skip LZW minimum code size byte.
427+
_position += 1;
428+
_skipDataBlocks();
429+
}
430+
431+
/// Returns [true] if the next block is a Graphic Control Extension block.
432+
bool _checkForGraphicControlExtension() {
433+
final int nextByte = bytes.getUint8(_position);
434+
if (nextByte != 0x21) {
435+
// This is not an extension block.
436+
return false;
437+
}
438+
439+
final int extensionLabel = bytes.getUint8(_position + 1);
440+
// The Graphic Control Extension label is 0xF9.
441+
return extensionLabel == 0xf9;
442+
}
443+
444+
/// Skip past the Graphic Control Extension block.
445+
void _skipGraphicControlExtension() {
446+
assert(_checkForGraphicControlExtension());
447+
// The Graphic Control Extension block is 8 bytes.
448+
_position += 8;
449+
}
450+
451+
/// Check if the next block is a Plain Text Extension block.
452+
bool _checkForPlainTextExtension() {
453+
final int nextByte = bytes.getUint8(_position);
454+
if (nextByte != 0x21) {
455+
// This is not an extension block.
456+
return false;
457+
}
458+
459+
final int extensionLabel = bytes.getUint8(_position + 1);
460+
// The Plain Text Extension label is 0x01.
461+
return extensionLabel == 0x01;
462+
}
463+
464+
/// Skip the Plain Text Extension block.
465+
void _skipPlainTextExtension() {
466+
assert(_checkForPlainTextExtension());
467+
// Skip the 15 bytes before the data sub-blocks.
468+
_position += 15;
469+
470+
_skipDataBlocks();
471+
}
472+
473+
/// Skip past any data sub-blocks and the block terminator.
474+
void _skipDataBlocks() {
475+
while (true) {
476+
final int blockSize = _readUint8();
477+
if (blockSize == 0) {
478+
// This is a block terminator.
479+
return;
480+
}
481+
_position += blockSize;
482+
}
483+
}
484+
485+
/// Read a 3 digit character code.
486+
String _readCharCode() {
487+
final List<int> chars = <int>[
488+
bytes.getUint8(_position),
489+
bytes.getUint8(_position + 1),
490+
bytes.getUint8(_position + 2),
491+
];
492+
_position += 3;
493+
return String.fromCharCodes(chars);
494+
}
495+
496+
int _readUint8() {
497+
final int result = bytes.getUint8(_position);
498+
_position += 1;
499+
return result;
500+
}
501+
}

lib/web_ui/test/engine/image_format_detector_test.dart

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,27 @@ Future<void> testMain() async {
5858
'stoplight.webp',
5959
];
6060

61+
// GIF files which are known to be animated.
62+
const List<String> animatedGifFiles = <String>[
63+
'alphabetAnim.gif',
64+
'colorTables.gif',
65+
'flightAnim.gif',
66+
'gif-transparent-index.gif',
67+
'randPixelsAnim.gif',
68+
'randPixelsAnim2.gif',
69+
'required.gif',
70+
'test640x479.gif',
71+
'xOffsetTooBig.gif',
72+
];
73+
6174
final String testFileExtension =
6275
testFile.substring(testFile.lastIndexOf('.') + 1);
6376
final ImageType? expectedImageType = switch (testFileExtension) {
6477
'jpg' => ImageType.jpeg,
6578
'jpeg' => ImageType.jpeg,
66-
'gif' => ImageType.animatedGif,
79+
'gif' => animatedGifFiles.contains(testFile)
80+
? ImageType.animatedGif
81+
: ImageType.gif,
6782
'webp' => animatedWebpFiles.contains(testFile)
6883
? ImageType.animatedWebp
6984
: ImageType.webp,

0 commit comments

Comments
 (0)