diff --git a/flutter_cache_manager/CHANGELOG.md b/flutter_cache_manager/CHANGELOG.md index 275c791b..639057ee 100644 --- a/flutter_cache_manager/CHANGELOG.md +++ b/flutter_cache_manager/CHANGELOG.md @@ -1,3 +1,9 @@ +## [3.4.7] - 2026-10-27 + +* Detects whether a downloaded file can be resized by letting the image engine decode it, instead of from the file extension derived from the `Content-Type` header of the response. Images served with a generic `Content-Type` such as `application/octet-stream` are now resized, files that are not images are returned untouched, and every format the engine supports is resized, including webp and bmp ([#353](https://github.com/Baseflow/flutter_cache_manager/issues/353)). Animated images (gif, apng, animated webp) are passed through untouched, since the resized image is always encoded as png and would lose its animation ([#475](https://github.com/Baseflow/flutter_cache_manager/pull/475)) +* Stores resized images with a `.png` extension, matching the encoding that is used, instead of reusing the extension derived from the `Content-Type` header +* Deprecates `supportedFileNames`, which is no longer used now that detection is content based + ## [3.4.6] - 2026-10-27 * Catches unhandled asynchronous errors when updating cache-hit metadata and during scheduled cleanup in `CacheStore` ([#535](https://github.com/Baseflow/flutter_cache_manager/issues/535)). diff --git a/flutter_cache_manager/lib/src/cache_managers/image_cache_manager.dart b/flutter_cache_manager/lib/src/cache_managers/image_cache_manager.dart index 5851a7ab..976c8f01 100644 --- a/flutter_cache_manager/lib/src/cache_managers/image_cache_manager.dart +++ b/flutter_cache_manager/lib/src/cache_managers/image_cache_manager.dart @@ -3,10 +3,22 @@ import 'dart:math'; import 'dart:ui' as ui; import 'package:file/file.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +/// Formats that used to be recognized by the extension of the file. +/// +/// Whether a file can be resized is now decided by the image engine, based on +/// the content of the file, so this list no longer reflects which files are +/// resized. It is kept only for backwards compatibility. +@Deprecated( + 'Image formats are detected from the file content instead of the file ' + 'extension. This list is no longer used and will be removed in a future ' + 'version.', +) const supportedFileNames = ['jpg', 'jpeg', 'png', 'tga', 'cur', 'ico']; + mixin ImageCacheManager on BaseCacheManager { /// Returns a resized image file to fit within maxHeight and maxWidth. It /// tries to keep the aspect ratio. It stores the resized image by adding @@ -73,27 +85,33 @@ mixin ImageCacheManager on BaseCacheManager { int? maxWidth, int? maxHeight, ) async { - final originalFileName = originalFile.file.path; - final fileExtension = originalFileName.split('.').last; - if (!supportedFileNames.contains(fileExtension)) { + final info = await _imageInfo(originalFile.file); + if (info == null) { + // The engine cannot decode this file, so it is not a resizable image. + // The original file is returned untouched instead of erroring the + // stream. return originalFile; } + final (frameCount, imageWidth, imageHeight) = info; - final image = await _decodeImage(originalFile.file); + // Resizing re-encodes the image as png, which keeps a single frame, so + // animated images (gif, apng, animated webp, ...) are passed through + // untouched. + if (frameCount > 1) { + return originalFile; + } - final shouldResize = maxWidth != null - ? image.width > maxWidth - : false || maxHeight != null - ? image.height > maxHeight - : false; + final shouldResize = + (maxWidth != null && imageWidth > maxWidth) || + (maxHeight != null && imageHeight > maxHeight); if (!shouldResize) return originalFile; if (maxWidth != null && maxHeight != null) { - final resizeFactorWidth = image.width / maxWidth; - final resizeFactorHeight = image.height / maxHeight; + final resizeFactorWidth = imageWidth / maxWidth; + final resizeFactorHeight = imageHeight / maxHeight; final resizeFactor = max(resizeFactorHeight, resizeFactorWidth); - maxWidth = (image.width / resizeFactor).round(); - maxHeight = (image.height / resizeFactor).round(); + maxWidth = (imageWidth / resizeFactor).round(); + maxHeight = (imageHeight / resizeFactor).round(); } final resized = await _decodeImage( @@ -111,7 +129,12 @@ mixin ImageCacheManager on BaseCacheManager { resizedFile, key: key, maxAge: maxAge, - fileExtension: fileExtension, + // The resized image is always encoded as png, so it is stored with a + // png extension. The extension of the original file cannot be reused: + // it comes from the Content-Type header of the response, which can be + // something generic like `application/octet-stream` instead of the + // format the image is actually encoded in. + fileExtension: 'png', ); return FileInfo( @@ -147,6 +170,61 @@ mixin ImageCacheManager on BaseCacheManager { } } +/// Describes the image in [file], or `null` when the engine cannot decode it. +/// +/// Whether a file is a resizable image is decided entirely by the engine: it +/// knows every format Flutter can decode, including platform specific ones +/// like heif, so there is no list of signatures or extensions to maintain. +/// The returned record holds the frame count of the image, which is how +/// animated images are recognized, and its width and height. +Future<(int frameCount, int width, int height)?> _imageInfo(File file) async { + if (!await file.exists()) { + return null; + } + final buffer = await _bufferFor(file); + ui.ImageDescriptor? descriptor; + ui.Codec? codec; + try { + descriptor = await ui.ImageDescriptor.encoded(buffer); + codec = await descriptor.instantiateCodec(); + // `descriptor.width` and `descriptor.height` are not supported on web + // (they throw), so the size is taken from the first frame instead. This + // fully decodes that frame, which costs the same as the decode the + // resize performs anyway. + final frame = await codec.getNextFrame(); + try { + return (codec.frameCount, frame.image.width, frame.image.height); + } finally { + frame.image.dispose(); + } + } on Object { + return null; + } finally { + codec?.dispose(); + descriptor?.dispose(); + buffer.dispose(); + } +} + +/// Creates the buffer for [file]. +/// +/// On web there is no file path to read from, so the bytes are read into +/// memory. On IO the path is preferred, so the bytes are not copied into +/// memory before we know whether the file is an image at all (it can be a +/// large video or pdf). Tests run against a mocked file system where the +/// path does not exist on the real file system, so reading the bytes is the +/// fallback there. +Future _bufferFor(File file) async { + if (kIsWeb) { + return ui.ImmutableBuffer.fromUint8List(await file.readAsBytes()); + } + try { + return await ui.ImmutableBuffer.fromFilePath(file.path); + } on Object { + return ui.ImmutableBuffer.fromUint8List(await file.readAsBytes()); + } +} + Future _decodeImage( File file, { int? width, diff --git a/flutter_cache_manager/pubspec.yaml b/flutter_cache_manager/pubspec.yaml index 8456c4b5..c3d22cd7 100644 --- a/flutter_cache_manager/pubspec.yaml +++ b/flutter_cache_manager/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_cache_manager description: Generic cache manager for flutter. Saves web files on the storages of the device and saves the cache info using sqflite. -version: 3.4.6 +version: 3.4.7 homepage: https://github.com/Baseflow/flutter_cache_manager topics: - cache diff --git a/flutter_cache_manager/test/helpers/mock_file_service.dart b/flutter_cache_manager/test/helpers/mock_file_service.dart index 9c191dbf..39aec863 100644 --- a/flutter_cache_manager/test/helpers/mock_file_service.dart +++ b/flutter_cache_manager/test/helpers/mock_file_service.dart @@ -1,10 +1,11 @@ -import 'dart:io'; +import 'dart:convert'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/src/web/file_service.dart'; import 'package:mockito/mockito.dart'; import '../mock.mocks.dart'; +import '../test_images.dart'; class MockFileService extends MockFileServiceBase { MockFileService._(); @@ -26,7 +27,7 @@ class MockFileService extends MockFileServiceBase { class TestResponse extends FileServiceResponse { @override Stream> get content async* { - var bytes = await File('test/images/image-120.png').readAsBytes(); + var bytes = base64Decode(imagePng120); var length = bytes.length; var firstPart = (length / 2).floor(); yield bytes.sublist(0, firstPart); diff --git a/flutter_cache_manager/test/image_cache_manager_test.dart b/flutter_cache_manager/test/image_cache_manager_test.dart index ce8ec599..0e1593c0 100644 --- a/flutter_cache_manager/test/image_cache_manager_test.dart +++ b/flutter_cache_manager/test/image_cache_manager_test.dart @@ -1,4 +1,4 @@ -import 'dart:io'; +import 'dart:convert'; import 'dart:typed_data'; import 'dart:ui'; @@ -9,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'cache_manager_test.dart'; import 'helpers/config_extensions.dart'; import 'helpers/test_configuration.dart'; +import 'test_images.dart'; const fileName = 'test.jpg'; const fileUrl = 'baseflow.com/test'; @@ -24,7 +25,7 @@ void main() { group('Test image resizing', () { test('Test original image size', () async { - final bytes = await getExampleImage(); + final bytes = exampleImage; await verifySize(bytes, 120, 120); }); @@ -127,6 +128,143 @@ void main() { expect(progress, isNotEmpty); }); }); + + group('Test image detection', () { + test('Image without file extension should be resized', () async { + const urlWithoutExtension = 'baseflow.com/avatar/123456'; + var config = createTestConfig(); + await config.returnsFile('avatar', data: exampleImage); + config.returnsCacheObject(urlWithoutExtension, 'avatar', validTill); + + var cacheManager = TestCacheManager(config); + var result = + await cacheManager + .getImageFile(urlWithoutExtension, maxWidth: 100) + .last + as FileInfo; + + var image = await result.file.readAsBytes(); + await verifySize(image, 100, 100); + }); + + test('Image with a non-image extension should be resized', () async { + // The stored file's extension does not come from the url, but from the + // Content-Type header of the response. A generic type such as + // application/octet-stream stores the file as .bin, even when the + // payload is an image. + const urlWithBinaryExtension = 'baseflow.com/image.bin'; + var config = createTestConfig(); + await config.returnsFile('image.bin', data: exampleImage); + config.returnsCacheObject(urlWithBinaryExtension, 'image.bin', validTill); + + var cacheManager = TestCacheManager(config); + var result = + await cacheManager + .getImageFile(urlWithBinaryExtension, maxWidth: 100) + .last + as FileInfo; + + var image = await result.file.readAsBytes(); + await verifySize(image, 100, 100); + expect(result.file.path.endsWith('.png'), isTrue); + }); + + test('Static webp should be resized', () async { + const urlWithWebpExtension = 'baseflow.com/image.webp'; + var config = createTestConfig(); + await config.returnsFile('image.webp', data: exampleWebp); + config.returnsCacheObject(urlWithWebpExtension, 'image.webp', validTill); + + var cacheManager = TestCacheManager(config); + var result = + await cacheManager + .getImageFile(urlWithWebpExtension, maxWidth: 100) + .last + as FileInfo; + + var image = await result.file.readAsBytes(); + await verifySize(image, 100, 100); + }); + + test('Bmp should be resized', () async { + const urlWithBmpExtension = 'baseflow.com/image.bmp'; + var config = createTestConfig(); + await config.returnsFile('image.bmp', data: exampleBmp); + config.returnsCacheObject(urlWithBmpExtension, 'image.bmp', validTill); + + var cacheManager = TestCacheManager(config); + var result = + await cacheManager + .getImageFile(urlWithBmpExtension, maxWidth: 100) + .last + as FileInfo; + + var image = await result.file.readAsBytes(); + await verifySize(image, 100, 100); + }); + + test('Non image should not be resized', () async { + const urlWithImageExtension = 'baseflow.com/image.png'; + var payload = utf8.encode('{"message":"not an image"}'); + var config = createTestConfig(); + await config.returnsFile('image.png', data: payload); + config.returnsCacheObject(urlWithImageExtension, 'image.png', validTill); + + var cacheManager = TestCacheManager(config); + var result = + await cacheManager + .getImageFile(urlWithImageExtension, maxWidth: 100) + .last + as FileInfo; + + expect(await result.file.readAsBytes(), payload); + }); + + test('Animated gif should not be resized', () async { + const urlOfAnimatedGif = 'baseflow.com/animation.gif'; + var payload = animatedGif; + var config = createTestConfig(); + await config.returnsFile('animation.gif', data: payload); + config.returnsCacheObject(urlOfAnimatedGif, 'animation.gif', validTill); + + var cacheManager = TestCacheManager(config); + var result = + await cacheManager.getImageFile(urlOfAnimatedGif, maxWidth: 100).last + as FileInfo; + + expect(await result.file.readAsBytes(), payload); + }); + + test('Animated webp should not be resized', () async { + const urlOfAnimatedWebp = 'baseflow.com/animation.webp'; + var payload = animatedWebp; + var config = createTestConfig(); + await config.returnsFile('animation.webp', data: payload); + config.returnsCacheObject(urlOfAnimatedWebp, 'animation.webp', validTill); + + var cacheManager = TestCacheManager(config); + var result = + await cacheManager.getImageFile(urlOfAnimatedWebp, maxWidth: 100).last + as FileInfo; + + expect(await result.file.readAsBytes(), payload); + }); + + test('Animated png should not be resized', () async { + const urlOfAnimatedPng = 'baseflow.com/animation.png'; + var payload = animatedApng; + var config = createTestConfig(); + await config.returnsFile('animation.png', data: payload); + config.returnsCacheObject(urlOfAnimatedPng, 'animation.png', validTill); + + var cacheManager = TestCacheManager(config); + var result = + await cacheManager.getImageFile(urlOfAnimatedPng, maxWidth: 100).last + as FileInfo; + + expect(await result.file.readAsBytes(), payload); + }); + }); } Future setupCacheManager() async { @@ -136,7 +274,7 @@ Future setupCacheManager() async { Future setupConfig({String? cacheKey}) async { var validTill = DateTime.now().add(const Duration(days: 1)); var config = createTestConfig(); - await config.returnsFile(fileName, data: await getExampleImage()); + await config.returnsFile(fileName, data: exampleImage); config.returnsCacheObject(fileUrl, fileName, validTill, key: cacheKey); return config; } @@ -154,6 +292,9 @@ Future verifySize( expect(height, expectedHeight); } -Future getExampleImage() { - return File('test/images/image-120.png').readAsBytes(); -} +final Uint8List exampleImage = base64Decode(imagePng120); +final Uint8List exampleWebp = base64Decode(imageWebp120); +final Uint8List exampleBmp = base64Decode(imageBmp120); +final Uint8List animatedGif = base64Decode(animatedGif120); +final Uint8List animatedWebp = base64Decode(animatedWebp120); +final Uint8List animatedApng = base64Decode(animatedApng120); diff --git a/flutter_cache_manager/test/images/image-120.png b/flutter_cache_manager/test/images/image-120.png deleted file mode 100644 index a02e94a1..00000000 Binary files a/flutter_cache_manager/test/images/image-120.png and /dev/null differ diff --git a/flutter_cache_manager/test/test_images.dart b/flutter_cache_manager/test/test_images.dart new file mode 100644 index 00000000..9dd1cfa5 --- /dev/null +++ b/flutter_cache_manager/test/test_images.dart @@ -0,0 +1,138 @@ +/// Images used by the tests, embedded as base64 so they can be decoded +/// without a file system. +/// +/// `flutter test --platform chrome` cannot read files: `dart:io` is not +/// available there. Keeping the bytes in the test bundle lets the same tests +/// run on the VM and in the browser, so the resize behaviour on web (where +/// `ImageDescriptor.width` and `height` are not supported) is covered too. +/// +/// All of them are 120x120, the minimal size the resize tests need, and are +/// kept as small as possible to stay readable. They were made with Pillow: +/// +/// ```python +/// img = Image.new('RGB', (120, 120), (32, 96, 192)) +/// img.quantize(colors=8).save('image-120.png', optimize=True) +/// ``` +library; + +/// Static 120x120 png. +const imagePng120 = + 'iVBORw0KGgoAAAANSUhEUgAAAHgAAAB4AgMAAABECt5BAAAACVBMVEXwyB4gYMDIKCiUsMyeAA' + 'ABLElEQVR42u2WMY6DMBBFvwlIKV1kqbfMMXwEit2jpcwNUnAEH4UDpKAHydtkE+aPjWIJ7VKY' + 'Ann8mD/jGRswkNeHE2aF1avgggsueH+41lNNh3BLejcdYL5S2HSvewQ//BoXxc3v4AQAOBC+Po' + 'O0g/ZemDYifqYwJH5ZjNuBvYVllfiZF3FIagPtIL0pT0szR9WunH5/UncotQv5tkO1tnNsTuyj' + '3mlVOjPAoP5efF7XxZ2i7v3UjKZ2szMWOU/1ZuJWU/NXLw9XXpp7w/4fY4+ahs3EZ03nzcSDpm' + 'OGuC66z/BWZQsCq4XPOR1TKxulN6fuJR45s6zdMnPiEgfOjMQ9WxLfl8ak+h14HRWS6l7ju9Rm' + 'HKhGXJb++aCP4UmGUUV9/GtNPo5D/7oDP+j1N9kWT81DAAAAAElFTkSuQmCC'; + +/// Static 120x120 webp. +const imageWebp120 = + 'UklGRkwCAABXRUJQVlA4IEACAACQEQCdASp4AHgAPrVUpk0nJKOiJhqqAOAWiU3cLiYSKsP51q' + '/3K/yI5wvQJc751vEv+O+6D3Af1X2AeJL/ouoB5hv2f/Yz3gPRf6AH9V/wHWM+hf5ZPsc/tv6U' + 'GaAALATZ5zdWBUWGhxz4FTEPHNR0FuBf+9ssCqNsqCPqki1UQ64OdJja38kSq5/xgZ5tuoLo8J' + 'yBwAD+/J6P50uqCJS7mhfSQwrrk/5whJQucgetgOr9CbPgqPoP5Mtthf7Zsg3LCZJJVeRgMYar' + 'xMaOl6ibLZ13kJWCCHd0SFjAZOv6vcMEAlCrdGnpiGMKMuc6xpxp5y0JeDddJwx8v4hbFiSiHa' + 'O+g5jKsu3/Uza13TTikWaTPxxHXB9GzQogAx0VyHkTTSH6SHz5Q9U8n3b9ceAvd2MqPOWASocs' + 'AcPJXUH0L37dNrBrpXkHelAHkNtrhp6odEkbgDO5ygFKJ7hLEITJ+f5khCBWLCLE0VM9yhb/sC' + 'p/po7UFmju89OX0T2nymo6io3i+K0FXloS8G2ALP8EpHOHpVZ/+fg7ZuNW/+DjDOVAdBJ8SncC' + 'ShE+X3ZjAhAj2toEDwxfRmCvqeAYyHvqf3TNWXrIqbTgyobZLU4zhFs6DCxRH1L8zKcev/ducr' + 'ijGfLQPbmd0myX/QL0s2bQrlv/iKwtbK6CZ2zLzovSHJFy83eD4mb+gBHGhWs4kKnxit4YGnaR' + 'VTcx/fGt4PFTsKybpx+1iJjLZ8qEfDV8LwiWchM9zC8nok5K60dkoAA='; + +/// Static 120x120 bmp. +const imageBmp120 = + 'Qk2+BwAAAAAAAD4AAAAoAAAAeAAAAHgAAAABAAEAAAAAAIAHAADEDgAAxA4AAAIAAAACAAAAAA' + 'AAAP///wCSSSSUklJJSSySkkkkooQAJJJJISSEkpKBJSSSSRQpAEkkkkpJKSUkqkhKVJSlUgCS' + 'SSSUklJQSSSSkoEikIQAJJZJISSEipJJJSSqVCUpAElAkkpJKSkkkkhJJIVIkgCSVSSUkpKSSS' + 'SSkkkolSQAJJJJISREpJJJKSSSRSJJAEkkkkpJqQkkkkRJJVCUkgCSSSSklBJSSSSqkkgtSSQA' + 'JJJJESKkpJJJCSSVQJJJAEkkkmpUSQkkklJJJJUkkgCSSSUEgpJSySykkkkqSSQAJJJIqVUkiB' + 'KBCSSSQJVJAEkqlRJISVKkqlJJJJUgkgCSQSSkkpIkSSSkqkkiVSQAJKpJKSUlSpJJCQSSVJJZ' + 'AEkkkkJISSkkklKpJIkkggCSSSSUkpJCSSUkkklSSSoAJJJJKSkklJJIiSSSJJJUAEkkkkJESS' + 'kklVJJKUkkgQCSSSSUqpJSSSEkkkpSSSoAJJJJKQkkhJJKSSSQhJJUAEkkklJSSSkklJJJJSkk' + 'gQCSSSUEpJJSSSEkkkpSSSoAJJJIqQkkhKJKSSSQhJJEAEkqkpJSSSlUlJJJJSkkqQCSQSUkpJ' + 'JECSEkkkpEkpIAJKpISQkkqVJKSSSQqSQkAEkkkpJSyRIklJLJJRKpSQCSSSUkiBKlSSEkEkik' + 'EpIAJJJISVKklJKWSqSVSqQkAEkkkpIkSSEkQJCSUhJJSQCSSSUpSpJKSrUlJISklJIAJJSQpS' + 'kklJECSlKpSSIkAEklVQhCSSEqVJCUEhJVSQCSSEJSlJJKQUlFIqSkhJIAJJKUpEkkpKoSKkpJ' + 'SSkkAEklKQqSSSkkpVCUkhJSSgCSSEJRJJJKSRCFISqkhJEAJJKUikkkkJKlKkpISSkqAEkkUl' + 'SSSSUkSJCUkpJSQQCSioUhJJJKSpUlISUlBKoAJFEpSkkkkJJSSkpISKkkAElKRCqVSSUkhJCk' + 'kpUSSQCSkqlAoJJSSSklESUkpJIAJCQlVRUkiJJEkqpKSUkkAElJSBJSSVUkqUgkkJKSSQCSkp' + 'akpJISSRIlSSUkJJIAJCQgSQkkpJKlVJJKSUkkAElJSpJSSUkklCEkkJKSSQCSkpEkpJISSSFK' + 'SSUkJJIAJCQqSQkkpJJKlJJKSUkkAElJRJJSUUkklCEokJSSSQCSJSkkiUoSSSFKRSUiJVIAJU' + 'hSSVIUqSJalJSSVUgkAEiShJIkoUSUgSEpSoSVSQBVKSlVSkoVSSpKQlApJJIAAEREABEUqFJU' + 'lJSVUkkkAP+7uv/u6UKEgSkpJISSSQCq7u+qu7oVKSpCQkkpJJIA/7u9f+7uqFJElJSSUkkkAK' + 'ru1+q7ukKEqSlJJISSSQD/u/6/7u6VKRJCIkkpJJIAqu6r6ru7KESklVSSSklJAP+7/r/u78Wp' + 'SShJJKESJACq7qvqu7rIEhJCkkkWpUkA/7v+3+7vsqSklSSUoEkqAKruq7q7uuRJEkhJIkqSQQ' + 'D/u/737u+6lKSSklURJKoAqu7V3Vu26kJKSSSEqkkkAP+7f7f++76UkSRJKSSSSQCq77b+q67r' + 'qyqSklJJJJIA/7r7q/77fqBCSSSEkkkkAKrvrv6rr6vqlKRJKSSSSQD/uvur/vr+uSkqklJJJJ' + 'IAqu+u/quvq+pCQkkEkkkkAP+2+6v++v65FJSkqSSSSQCq+69+q6+r6qkpKVJJJJIA/6766/76' + '/rhCQkoEkkkkAKr7b96rr6vqlJSSqSWSSQD/r7t1/vr+uSkpJJJIJJIAqvrt36uvq+pCQkkkpU' + 'kkAP+vvvr++365FJSSSRCSSQCq+uuvq63r6mlJJJJVJJIA/6+++37/vtkCIkkpQlUkAKr6663r' + 'qut6VVSSShSCVQD/r77/vv+/6ohJJJKpVIIAqvrrquuq6rhSkkkkkklUAP+vvv++/7/qpSSSSS' + 'SSSQCq+uuq66rquQhJSJJJJJIA/6++/77/v+pSkiUkkkkkAKr666rrquq5JSVSSSSSSQD/t79/' + 'v3+/6khIJJJJJJIAqt7q6urq6rkqlUkkkkkkAP/7v9+/37/qQSSSSSSSSQCqru167XrtWSpJJJ' + 'JJJJIA//u717vXu/pEkkkkkkkkAKqu737vfu9pKSSSSSSSSQD/+73bvdu92pJJJJJJJJIAqq7X' + 'btdu13hUkkkkkkkkAP/7/fv9+/3qgSVSqVSqSQCqrVatVq1WuSpIJBIJBJYA/////////+pElU' + 'qlUqlAAKqqqqqqqqq0qSSSSSSSVQD/////////+RJJJJJJJJIAqqqqqqqqqqqkkkkkkkkkAP//' + '///////4SSSSSSSSSQCqqqqqqqqqqpJJJJJJJJIA//////////kkkkkkkkkkAKqqqqqqqqqqUS' + 'SSSSSSSQD/////////+QpJJJJJJJIAqqqqqqqqqqpUkkkkkkkkAP/////////5ISSSSSSSSQCq' + 'qqqqqqqqqkpJJJJJJJIA//////////kkkkkkkkkkAKqqqqqqqqqqSSSSSSSSSQD/////////+S' + 'SSSSSSSSQAqqqqqqqqqqpJJJJJJJJJAP/////////5JJJJJJJJJAA='; + +/// Animated (3 frames) 120x120 gif. +const animatedGif120 = + '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'; + +/// Animated (3 frames) 120x120 webp. +const animatedWebp120 = + 'UklGRsACAABXRUJQVlA4WAoAAAASAAAAdwAAdwAAQU5JTQYAAAAAAAAAAABBTk1GngAAAAAAAA' + 'AAAHcAAHcAAGQAAAJWUDgghgAAADAIAJ0BKngAeAA+0WStUaglJCKknjgBABoJZ27dX3DAABu+' + 'iAfwGtaZ9ow/6ZF5Iumvx5lQGRlGupsIxacZ2/KzBCi0yBCl3ZgAAP7zB/EqJeD9BD//N5uQfy' + 'UzKbkH27f+vb+By5BynUViE1OcSDwTUi2oi3VHfCEwysjzhCYZWR5whAAAQU5NRuoAAAAAAAAA' + 'AAB3AAB3AABkAAAAQUxQSBYAAAABDzD/ERFCTEPoX1oCpy2i/xNgOpwIVlA4ILQAAAC0CQCdAS' + 'p4AHgAPtFiqk8CiIAAAaCWVu3V9bJn+IGz4cz/CBLAADnRE39Ug42uYKZLMS8Ri+tzVIopfv5l' + '/v2p1JynJZM+zaHpTzvmX+/bGm0Zll2MAAD+3hozTB+Z4Lt/gXHce/tFTcvMFZfsxNPrQESvY+' + 'aw+mSv+2OB6uNl/a0ZtOJY+besf+WgR/l67hT9e3ns2d6U4dM1JoigbeEVEjMCCfF56uEOzIAI' + 'wcgAjhyAAABBTk1G/AAAAAAAAAAAAHcAAHcAAGQAAABBTFBIFgAAAAEPMP8REUJMQ+hfWgKnLa' + 'L/E2A+XAhWUDggxgAAAPQIAJ0BKngAeAA+0WKmTwKIlYABoJY27dXpzawCJqvkzIfQGNaRvTBb' + 'SX3uFhmq+sJIY+oFjax57YzSxIwT2xmhOT2kLnIVkYJ7YzSxIwHAAP7tCH//zNH75PsN5Af+J/' + 'D4djBSt/0IMH/t/qif6mdXfa0f1M74VG3IL/NWrelE4S4m+dTGVYWq+dUrY9Mh5VyiMuw3TD4g' + 'rStK/suynznFY9+IIR1h27tiyviT1KWzOMs0khTL+rzSBUKzAgTvyksmlCQAAA=='; + +/// Animated (3 frames) 120x120 png. +const animatedApng120 = + 'iVBORw0KGgoAAAANSUhEUgAAAHgAAAB4AQMAAAADqqSRAAAABlBMVEUAgP8KCgrdKYvuAAAACG' + 'FjVEwAAAADAAAAAM7tusAAAAAaZmNUTAAAAAAAAAB4AAAAeAAAAAAAAAAAAAEACgAAjNzB+AAA' + 'ACVJREFUeNpjYGBgYGBgkP8PBUwMqGCUP8of5Y/yR/mj/FE+9fkA17QJBpLlpncAAAAaZmNUTA' + 'AAAAEAAAB4AAAAeAAAAAAAAAAAAAEACgAAF68rLAAAAClmZEFUAAAAAnja7cchEgAgDMCwHnyc' + 'n3caP9m4AFxVfQCHX++99973P/4eB2zCVlVhAAAAGmZjVEwAAAADAAAAeAAAAHgAAAAAAAAAAA' + 'ABAAoAAPo5+MUAAAArZmRBVAAAAAR42mNggAD+////////j4GJARWM8kf5o/xR/ih/lD/Kpz4f' + 'AH2rBvcc4MJOAAAAAElFTkSuQmCC';