From bc01efa124dd8ae2432adb03bc6f62037f9c757a Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 25 Mar 2022 16:31:40 +0000 Subject: [PATCH] Improve handling of animated GIF and WEBP images (#8153) --- package.json | 1 + src/ContentMessages.ts | 8 +- src/components/views/messages/MImageBody.tsx | 264 ++++++++++-------- .../views/messages/MImageReplyBody.tsx | 6 +- src/utils/Image.ts | 76 +++++ src/utils/blobs.ts | 1 + test/Image-test.ts | 61 ++++ test/images/animated-logo.gif | Bin 0 -> 55614 bytes test/images/animated-logo.webp | Bin 0 -> 5192 bytes test/images/static-logo.gif | Bin 0 -> 999 bytes test/images/static-logo.webp | Bin 0 -> 146 bytes test/setupTests.js | 1 + yarn.lock | 5 + 13 files changed, 297 insertions(+), 126 deletions(-) create mode 100644 src/utils/Image.ts create mode 100644 test/Image-test.ts create mode 100644 test/images/animated-logo.gif create mode 100644 test/images/animated-logo.webp create mode 100644 test/images/static-logo.gif create mode 100644 test/images/static-logo.webp diff --git a/package.json b/package.json index ab85c03ea8..acd1b63c79 100644 --- a/package.json +++ b/package.json @@ -166,6 +166,7 @@ "@wojtekmaj/enzyme-adapter-react-17": "^0.6.1", "allchange": "^1.0.6", "babel-jest": "^26.6.3", + "blob-polyfill": "^6.0.20211015", "chokidar": "^3.5.1", "concurrently": "^5.3.0", "enzyme": "^3.11.0", diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index 9a6d4da100..babe4563f7 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -106,15 +106,17 @@ interface IThumbnail { * @param {HTMLElement} element The element to thumbnail. * @param {number} inputWidth The width of the image in the input element. * @param {number} inputHeight the width of the image in the input element. - * @param {String} mimeType The mimeType to save the blob as. + * @param {string} mimeType The mimeType to save the blob as. + * @param {boolean} calculateBlurhash Whether to calculate a blurhash of the given image too. * @return {Promise} A promise that resolves with an object with an info key * and a thumbnail key. */ -async function createThumbnail( +export async function createThumbnail( element: ThumbnailableElement, inputWidth: number, inputHeight: number, mimeType: string, + calculateBlurhash = true, ): Promise { let targetWidth = inputWidth; let targetHeight = inputHeight; @@ -152,7 +154,7 @@ async function createThumbnail( const imageData = context.getImageData(0, 0, targetWidth, targetHeight); // thumbnailPromise and blurhash promise are being awaited concurrently - const blurhash = await BlurhashEncoder.instance.getBlurhash(imageData); + const blurhash = calculateBlurhash ? await BlurhashEncoder.instance.getBlurhash(imageData) : undefined; const thumbnail = await thumbnailPromise; return { diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx index cf379e79da..19e71b72bd 100644 --- a/src/components/views/messages/MImageBody.tsx +++ b/src/components/views/messages/MImageBody.tsx @@ -30,19 +30,25 @@ import SettingsStore from "../../../settings/SettingsStore"; import InlineSpinner from '../elements/InlineSpinner'; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { Media, mediaFromContent } from "../../../customisations/Media"; -import { BLURHASH_FIELD } from "../../../ContentMessages"; +import { BLURHASH_FIELD, createThumbnail } from "../../../ContentMessages"; import { IMediaEventContent } from '../../../customisations/models/IMediaEventContent'; import ImageView from '../elements/ImageView'; import { IBodyProps } from "./IBodyProps"; import { ImageSize, suggestedSize as suggestedImageSize } from "../../../settings/enums/ImageSize"; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; +import { blobIsAnimated, mayBeAnimated } from '../../../utils/Image'; + +enum Placeholder { + NoImage, + Blurhash, +} interface IState { - decryptedUrl?: string; - decryptedThumbnailUrl?: string; - decryptedBlob?: Blob; - error; + contentUrl?: string; + thumbUrl?: string; + isAnimated?: boolean; + error?: Error; imgError: boolean; imgLoaded: boolean; loadedImageDimensions?: { @@ -51,7 +57,7 @@ interface IState { }; hover: boolean; showImage: boolean; - placeholder: 'no-image' | 'blurhash'; + placeholder: Placeholder; } @replaceableComponent("views.messages.MImageBody") @@ -68,16 +74,11 @@ export default class MImageBody extends React.Component { super(props); this.state = { - decryptedUrl: null, - decryptedThumbnailUrl: null, - decryptedBlob: null, - error: null, imgError: false, imgLoaded: false, - loadedImageDimensions: null, hover: false, showImage: SettingsStore.getValue("showImages"), - placeholder: 'no-image', + placeholder: Placeholder.NoImage, }; } @@ -86,7 +87,7 @@ export default class MImageBody extends React.Component { if (this.unmounted) return; // Consider the client reconnected if there is no error with syncing. // This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP. - const reconnected = syncState !== "ERROR" && prevState !== syncState; + const reconnected = syncState !== SyncState.Error && prevState !== syncState; if (reconnected && this.state.imgError) { // Load the image again this.setState({ @@ -110,7 +111,7 @@ export default class MImageBody extends React.Component { } const content = this.props.mxEvent.getContent(); - const httpUrl = this.getContentUrl(); + const httpUrl = this.state.contentUrl; const params: Omit, "onFinished"> = { src: httpUrl, name: content.body?.length > 0 ? content.body : _t('Attachment'), @@ -139,29 +140,24 @@ export default class MImageBody extends React.Component { } }; - private isGif = (): boolean => { - const content = this.props.mxEvent.getContent(); - return content.info?.mimetype === "image/gif"; - }; - private onImageEnter = (e: React.MouseEvent): void => { this.setState({ hover: true }); - if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifs")) { + if (!this.state.showImage || !this.state.isAnimated || SettingsStore.getValue("autoplayGifs")) { return; } const imgElement = e.currentTarget; - imgElement.src = this.getContentUrl(); + imgElement.src = this.state.contentUrl; }; private onImageLeave = (e: React.MouseEvent): void => { this.setState({ hover: false }); - if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifs")) { + if (!this.state.showImage || !this.state.isAnimated || SettingsStore.getValue("autoplayGifs")) { return; } const imgElement = e.currentTarget; - imgElement.src = this.getThumbUrl(); + imgElement.src = this.state.thumbUrl ?? this.state.contentUrl; }; private onImageError = (): void => { @@ -175,7 +171,7 @@ export default class MImageBody extends React.Component { this.clearBlurhashTimeout(); this.props.onHeightChanged(); - let loadedImageDimensions; + let loadedImageDimensions: IState["loadedImageDimensions"]; if (this.image.current) { const { naturalWidth, naturalHeight } = this.image.current; @@ -185,22 +181,17 @@ export default class MImageBody extends React.Component { this.setState({ imgLoaded: true, loadedImageDimensions }); }; - protected getContentUrl(): string { - const content: IMediaEventContent = this.props.mxEvent.getContent(); + private getContentUrl(): string { // During export, the content url will point to the MSC, which will later point to a local url - if (this.props.forExport) return content.url || content.file?.url; - if (this.media.isEncrypted) { - return this.state.decryptedUrl; - } else { - return this.media.srcHttp; - } + if (this.props.forExport) return this.media.srcMxc; + return this.media.srcHttp; } private get media(): Media { return mediaFromContent(this.props.mxEvent.getContent()); } - protected getThumbUrl(): string { + private getThumbUrl(): string { // FIXME: we let images grow as wide as you like, rather than capped to 800x600. // So either we need to support custom timeline widths here, or reimpose the cap, otherwise the // thumbnail resolution will be unnecessarily reduced. @@ -210,85 +201,112 @@ export default class MImageBody extends React.Component { const content = this.props.mxEvent.getContent(); const media = mediaFromContent(content); + const info = content.info; - if (media.isEncrypted) { - // Don't use the thumbnail for clients wishing to autoplay gifs. - if (this.state.decryptedThumbnailUrl) { - return this.state.decryptedThumbnailUrl; - } - return this.state.decryptedUrl; - } else if (content.info && content.info.mimetype === "image/svg+xml" && media.hasThumbnail) { - // special case to return clientside sender-generated thumbnails for SVGs, if any, - // given we deliberately don't thumbnail them serverside to prevent - // billion lol attacks and similar + if (info?.mimetype === "image/svg+xml" && media.hasThumbnail) { + // Special-case to return clientside sender-generated thumbnails for SVGs, if any, + // given we deliberately don't thumbnail them serverside to prevent billion lol attacks and similar. return media.getThumbnailHttp(thumbWidth, thumbHeight, 'scale'); - } else { - // we try to download the correct resolution - // for hi-res images (like retina screenshots). - // synapse only supports 800x600 thumbnails for now though, - // so we'll need to download the original image for this to work - // well for now. First, let's try a few cases that let us avoid - // downloading the original, including: - // - When displaying a GIF, we always want to thumbnail so that we can - // properly respect the user's GIF autoplay setting (which relies on - // thumbnailing to produce the static preview image) - // - On a low DPI device, always thumbnail to save bandwidth - // - If there's no sizing info in the event, default to thumbnail - const info = content.info; - if ( - this.isGif() || - window.devicePixelRatio === 1.0 || - (!info || !info.w || !info.h || !info.size) - ) { - return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight); - } else { - // we should only request thumbnails if the image is bigger than 800x600 - // (or 1600x1200 on retina) otherwise the image in the timeline will just - // end up resampled and de-retina'd for no good reason. - // Ideally the server would pregen 1600x1200 thumbnails in order to provide retina - // thumbnails, but we don't do this currently in synapse for fear of disk space. - // As a compromise, let's switch to non-retina thumbnails only if the original - // image is both physically too large and going to be massive to load in the - // timeline (e.g. >1MB). - - const isLargerThanThumbnail = ( - info.w > thumbWidth || - info.h > thumbHeight - ); - const isLargeFileSize = info.size > 1 * 1024 * 1024; // 1mb - - if (isLargeFileSize && isLargerThanThumbnail) { - // image is too large physically and bytewise to clutter our timeline so - // we ask for a thumbnail, despite knowing that it will be max 800x600 - // despite us being retina (as synapse doesn't do 1600x1200 thumbs yet). - return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight); - } else { - // download the original image otherwise, so we can scale it client side - // to take pixelRatio into account. - return media.srcHttp; - } - } } + + // we try to download the correct resolution for hi-res images (like retina screenshots). + // Synapse only supports 800x600 thumbnails for now though, + // so we'll need to download the original image for this to work well for now. + // First, let's try a few cases that let us avoid downloading the original, including: + // - When displaying a GIF, we always want to thumbnail so that we can + // properly respect the user's GIF autoplay setting (which relies on + // thumbnailing to produce the static preview image) + // - On a low DPI device, always thumbnail to save bandwidth + // - If there's no sizing info in the event, default to thumbnail + if ( + this.state.isAnimated || + window.devicePixelRatio === 1.0 || + (!info || !info.w || !info.h || !info.size) + ) { + return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight); + } + + // We should only request thumbnails if the image is bigger than 800x600 (or 1600x1200 on retina) otherwise + // the image in the timeline will just end up resampled and de-retina'd for no good reason. + // Ideally the server would pre-gen 1600x1200 thumbnails in order to provide retina thumbnails, + // but we don't do this currently in synapse for fear of disk space. + // As a compromise, let's switch to non-retina thumbnails only if the original image is both + // physically too large and going to be massive to load in the timeline (e.g. >1MB). + + const isLargerThanThumbnail = ( + info.w > thumbWidth || + info.h > thumbHeight + ); + const isLargeFileSize = info.size > 1 * 1024 * 1024; // 1mb + + if (isLargeFileSize && isLargerThanThumbnail) { + // image is too large physically and byte-wise to clutter our timeline so, + // we ask for a thumbnail, despite knowing that it will be max 800x600 + // despite us being retina (as synapse doesn't do 1600x1200 thumbs yet). + return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight); + } + + // download the original image otherwise, so we can scale it client side to take pixelRatio into account. + return media.srcHttp; } private async downloadImage() { - if (this.props.mediaEventHelper.media.isEncrypted && this.state.decryptedUrl === null) { + if (this.state.contentUrl) return; // already downloaded + + let thumbUrl: string; + let contentUrl: string; + if (this.props.mediaEventHelper.media.isEncrypted) { try { - const thumbnailUrl = await this.props.mediaEventHelper.thumbnailUrl.value; - this.setState({ - decryptedUrl: await this.props.mediaEventHelper.sourceUrl.value, - decryptedThumbnailUrl: thumbnailUrl, - decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value, - }); - } catch (err) { + ([contentUrl, thumbUrl] = await Promise.all([ + this.props.mediaEventHelper.sourceUrl.value, + this.props.mediaEventHelper.thumbnailUrl.value, + ])); + } catch (error) { if (this.unmounted) return; - logger.warn("Unable to decrypt attachment: ", err); + logger.warn("Unable to decrypt attachment: ", error); // Set a placeholder image when we can't decrypt the image. - this.setState({ - error: err, + this.setState({ error }); + } + } else { + thumbUrl = this.getThumbUrl(); + contentUrl = this.getContentUrl(); + } + + const content = this.props.mxEvent.getContent(); + let isAnimated = mayBeAnimated(content.info?.mimetype); + + // If there is no included non-animated thumbnail then we will generate our own, we can't depend on the server + // because 1. encryption and 2. we can't ask the server specifically for a non-animated thumbnail. + if (isAnimated && !SettingsStore.getValue("autoplayGifs")) { + if (!thumbUrl || !content?.info.thumbnail_info || mayBeAnimated(content.info.thumbnail_info.mimetype)) { + const img = document.createElement("img"); + const loadPromise = new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; }); + img.crossOrigin = "Anonymous"; // CORS allow canvas access + img.src = contentUrl; + + await loadPromise; + + const blob = await this.props.mediaEventHelper.sourceBlob.value; + if (!await blobIsAnimated(content.info.mimetype, blob)) { + isAnimated = false; + } + + if (isAnimated) { + const thumb = await createThumbnail(img, img.width, img.height, content.info.mimetype, false); + thumbUrl = URL.createObjectURL(thumb.thumbnail); + } } } + + if (this.unmounted) return; + this.setState({ + contentUrl, + thumbUrl, + isAnimated, + }); } private clearBlurhashTimeout() { @@ -317,7 +335,7 @@ export default class MImageBody extends React.Component { this.timeout = setTimeout(() => { if (!this.state.imgLoaded || !this.state.imgError) { this.setState({ - placeholder: 'blurhash', + placeholder: Placeholder.Blurhash, }); } }, 150); @@ -333,6 +351,9 @@ export default class MImageBody extends React.Component { MatrixClientPeg.get().removeListener(ClientEvent.Sync, this.onClientSync); this.clearBlurhashTimeout(); SettingsStore.unwatchSetting(this.sizeWatcher); + if (this.state.isAnimated && this.state.thumbUrl) { + URL.revokeObjectURL(this.state.thumbUrl); + } } protected messageContent( @@ -341,10 +362,12 @@ export default class MImageBody extends React.Component { content: IMediaEventContent, forcedHeight?: number, ): JSX.Element { + if (!thumbUrl) thumbUrl = contentUrl; // fallback + let infoWidth: number; let infoHeight: number; - if (content && content.info && content.info.w && content.info.h) { + if (content.info?.w && content.info?.h) { infoWidth = content.info.w; infoHeight = content.info.h; } else { @@ -385,9 +408,9 @@ export default class MImageBody extends React.Component { forcedHeight ?? this.props.maxImageHeight, ); - let img = null; - let placeholder = null; - let gifLabel = null; + let img: JSX.Element; + let placeholder: JSX.Element; + let gifLabel: JSX.Element; if (!this.props.forExport && !this.state.imgLoaded) { placeholder = this.getPlaceholder(maxWidth, maxHeight); @@ -427,7 +450,8 @@ export default class MImageBody extends React.Component { showPlaceholder = false; // because we're hiding the image, so don't show the placeholder. } - if (this.isGif() && !SettingsStore.getValue("autoplayGifs") && !this.state.hover) { + if (this.state.isAnimated && !SettingsStore.getValue("autoplayGifs") && !this.state.hover) { + // XXX: Arguably we may want a different label when the animated image is WEBP and not GIF gifLabel =

GIF

; } @@ -489,9 +513,9 @@ export default class MImageBody extends React.Component { const blurhash = this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]; if (blurhash) { - if (this.state.placeholder === 'no-image') { + if (this.state.placeholder === Placeholder.NoImage) { return
; - } else if (this.state.placeholder === 'blurhash') { + } else if (this.state.placeholder === Placeholder.Blurhash) { return ; } } @@ -510,13 +534,15 @@ export default class MImageBody extends React.Component { if (this.props.forExport) return null; /* * In the room timeline or the thread context we don't need the download - * link as the message action bar will fullfil that + * link as the message action bar will fulfill that */ - const hasMessageActionBar = this.context.timelineRenderingType === TimelineRenderingType.Room - || this.context.timelineRenderingType === TimelineRenderingType.Pinned - || this.context.timelineRenderingType === TimelineRenderingType.Search - || this.context.timelineRenderingType === TimelineRenderingType.Thread - || this.context.timelineRenderingType === TimelineRenderingType.ThreadsList; + const hasMessageActionBar = ( + this.context.timelineRenderingType === TimelineRenderingType.Room || + this.context.timelineRenderingType === TimelineRenderingType.Pinned || + this.context.timelineRenderingType === TimelineRenderingType.Search || + this.context.timelineRenderingType === TimelineRenderingType.Thread || + this.context.timelineRenderingType === TimelineRenderingType.ThreadsList + ); if (!hasMessageActionBar) { return ; } @@ -525,7 +551,7 @@ export default class MImageBody extends React.Component { render() { const content = this.props.mxEvent.getContent(); - if (this.state.error !== null) { + if (this.state.error) { return (
@@ -534,12 +560,12 @@ export default class MImageBody extends React.Component { ); } - const contentUrl = this.getContentUrl(); - let thumbUrl; - if (this.props.forExport || (this.isGif() && SettingsStore.getValue("autoplayGifs"))) { + const contentUrl = this.state.contentUrl; + let thumbUrl: string; + if (this.props.forExport || (this.state.isAnimated && SettingsStore.getValue("autoplayGifs"))) { thumbUrl = contentUrl; } else { - thumbUrl = this.getThumbUrl(); + thumbUrl = this.state.thumbUrl ?? this.state.contentUrl; } const thumbnail = this.messageContent(contentUrl, thumbUrl, content); diff --git a/src/components/views/messages/MImageReplyBody.tsx b/src/components/views/messages/MImageReplyBody.tsx index bb179ba6c0..9edbcec304 100644 --- a/src/components/views/messages/MImageReplyBody.tsx +++ b/src/components/views/messages/MImageReplyBody.tsx @@ -41,14 +41,12 @@ export default class MImageReplyBody extends MImageBody { } render() { - if (this.state.error !== null) { + if (this.state.error) { return super.render(); } const content = this.props.mxEvent.getContent(); - - const contentUrl = this.getContentUrl(); - const thumbnail = this.messageContent(contentUrl, this.getThumbUrl(), content, FORCED_IMAGE_HEIGHT); + const thumbnail = this.messageContent(this.state.contentUrl, this.state.thumbUrl, content, FORCED_IMAGE_HEIGHT); const fileBody = this.getFileBody(); const sender = { + if (mimeType === "image/webp") { + // Only extended file format WEBP images support animation, so grab the expected data range and verify header. + // Based on https://developers.google.com/speed/webp/docs/riff_container#extended_file_format + const arr = await blob.slice(0, 17).arrayBuffer(); + if ( + arrayBufferReadStr(arr, 0, 4) === "RIFF" && + arrayBufferReadStr(arr, 8, 4) === "WEBP" && + arrayBufferReadStr(arr, 12, 4) === "VP8X" + ) { + const [flags] = arrayBufferRead(arr, 16, 1); + // Flags: R R I L E X _A_ R (reversed) + const animationFlagMask = 1 << 1; + return (flags & animationFlagMask) != 0; + } + } else if (mimeType === "image/gif") { + // Based on https://gist.github.com/zakirt/faa4a58cec5a7505b10e3686a226f285 + // More info at http://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp + const dv = new DataView(await blob.arrayBuffer(), 10); + + const globalColorTable = dv.getUint8(0); + let globalColorTableSize = 0; + // check first bit, if 0, then we don't have a Global Color Table + if (globalColorTable & 0x80) { + // grab the last 3 bits, to calculate the global color table size -> RGB * 2^(N+1) + // N is the value in the last 3 bits. + globalColorTableSize = 3 * Math.pow(2, (globalColorTable & 0x7) + 1); + } + + // move on to the Graphics Control Extension + const offset = 3 + globalColorTableSize; + + const extensionIntroducer = dv.getUint8(offset); + const graphicsControlLabel = dv.getUint8(offset + 1); + let delayTime = 0; + + // Graphics Control Extension section is where GIF animation data is stored + // First 2 bytes must be 0x21 and 0xF9 + if ((extensionIntroducer & 0x21) && (graphicsControlLabel & 0xF9)) { + // skip to the 2 bytes with the delay time + delayTime = dv.getUint16(offset + 4); + } + + return !!delayTime; + } + + return false; +} diff --git a/src/utils/blobs.ts b/src/utils/blobs.ts index 209a91f1ae..bf7251b61f 100644 --- a/src/utils/blobs.ts +++ b/src/utils/blobs.ts @@ -52,6 +52,7 @@ const ALLOWED_BLOB_MIMETYPES = [ 'image/jpeg', 'image/gif', 'image/png', + 'image/webp', 'video/mp4', 'video/webm', diff --git a/test/Image-test.ts b/test/Image-test.ts new file mode 100644 index 0000000000..9bd933a3a1 --- /dev/null +++ b/test/Image-test.ts @@ -0,0 +1,61 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import fs from "fs"; +import path from "path"; + +import './skinned-sdk'; +import { blobIsAnimated, mayBeAnimated } from "../src/utils/Image"; + +describe("Image", () => { + describe("mayBeAnimated", () => { + it("image/gif", async () => { + expect(mayBeAnimated("image/gif")).toBeTruthy(); + }); + it("image/webp", async () => { + expect(mayBeAnimated("image/webp")).toBeTruthy(); + }); + it("image/png", async () => { + expect(mayBeAnimated("image/png")).toBeFalsy(); + }); + it("image/jpeg", async () => { + expect(mayBeAnimated("image/jpeg")).toBeFalsy(); + }); + }); + + describe("blobIsAnimated", () => { + const animatedGif = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "animated-logo.gif"))]); + const animatedWebp = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "animated-logo.webp"))]); + const staticGif = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "static-logo.gif"))]); + const staticWebp = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "static-logo.webp"))]); + + it("Animated GIF", async () => { + expect(await blobIsAnimated("image/gif", animatedGif)).toBeTruthy(); + }); + + it("Static GIF", async () => { + expect(await blobIsAnimated("image/gif", staticGif)).toBeFalsy(); + }); + + it("Animated WEBP", async () => { + expect(await blobIsAnimated("image/webp", animatedWebp)).toBeTruthy(); + }); + + it("Static WEBP", async () => { + expect(await blobIsAnimated("image/webp", staticWebp)).toBeFalsy(); + }); + }); +}); diff --git a/test/images/animated-logo.gif b/test/images/animated-logo.gif new file mode 100644 index 0000000000000000000000000000000000000000..e3ee4f62062612cfc286fbdefff9f6566898ba08 GIT binary patch literal 55614 zcmeFaRaBdey8YWy1q!rKpm=eI;tnnD6k6Qfp~cL#%f#MFuo#5_HIDBVq z`>(TQoVE8}>zr>iBNw^L)%*P3`N*6SQsUe^`rVJZA6-0p^!R_|`}iOBdGh258XB6g zu&{`Th^VNjn3$NjxVVIbgruaTl$4aTw6u(jjI6AzoSdAzyu5;f!spMQ6%`egl$5@F z`SSJaS7l{o6%`d#RaG@LHFb4$4Gj%VO-(H=Ep2UW9UUEAU0pprJ$-$B0|Ns?Lqj7Y zBV%J@6B83tQ&TfDGjnru3kwTNOG_&&D{E_O8yg#2Tib8nzJ34x-OkR=-rnB9!NJkd z(aFil+1c5}#l_Xt)y>V#-QC^8!^6|l)62`t+uPg6$H&*#*U!(--`_tVARsU>FeoS} zI5;>YBqTI6G%PGEJUl!iA|f&}GAb%6IyyQgCMGsEHZCqMK0ZDnAt5m_F)1l2IXO8c zB_%aAH7zYIJv}`mBO^026951JfxxV+tnBRUoSdB8+}ympy!`z9f`Wp=!os4WA`l1! z27@6GNO5s-Nl6J53N0-yEh{T4FE6jCsHm*0tg5Q2uCA`Bsj024t*fi6udi=tXn?_B zjg5^>O-;?s%`Gi0KYsjZZEbC9YindAeCMG5)C#R;Srl+T8W@ct*XXobT=I7@Z z78Vv47nhcnmY0`TR#xC}`0DEF+S=Os`ufJk#^&bc*4Ebc_V&)s&hGB+-rnB+{{F$i z!QtWI(b3WI@$t#Y$?56o+1c6o`T51g#pUJY)z#JY_4Uoo&F$^&-Q67mfw;fFC%s3N z7yJ4}nW#Y93pJMCTx zma%cIgi5U;kx%W|L?EZ z;4HE3%7{1~)eXjM%f#k5UX>l%a?NqCI6mbA%2tF7won}Z*AvnaKIQvZfiD+C%gmkH zsR-kR%p>m!lC*oX`~1f)X+EGgb!omOq?c)a6uDCaxb#!- zf;n5e)I_6WS4qXY6U~MVy;y!4gul;pQ4Ax;Jk8O2IUkZM!Kxu@Xu!GcqV`F6zgsaH z`_63YqZoeu$9Q>@017c_bgI`0B7&im5x9QuGLk&rYKTJ+45B1tAKV@OR>3)~O1?K%}eJP(hQKxst5GUUP6O z)fPl1J&o2X0rkkD81hV5rntC|Zq};YS2{hbX5227zGQ5>!6L6o$-SgL${>BSeB^OB zQ|v6>Hr=!}A@7(&p{n@WaYG6Ta^a zC(w5+O=uERNDg6n*wULknn1S{Sc8@#pO&cwn#ybdE`$|VW?E6T! z8;@K0umsp!@y==O2V}6ePO7Net?X0ezG$yFFqgg7!xR*e|Jv=U9gF6rA_k+FTz2(Cc=_!^B%xe5S04GjdIPs z*782h*nkn5qlaYtOV>@}FZVHRGKv#|ibidHDUt#q|NTC+t359#k3v63qNAh#ChKpq{%vIaheLA=nj+(DGgs(%vfdQ_wR92NBYJ|eh^uU*H7Juz zzTbhxFh7dqBA?#X+^F9Bv1Y*>XQfGZ#yrBB@Y-gp$LR?Wr}5f)Cns6E@VUUOgY_?< z^+u=lCCA8Aj%_6lll#Rtc!v}gMigOF#sQ!3?{k`X1sOpj`>V(64b+pmb zCABw$?4&-(*ADt>*u7PxYDArReF>D^{@QCkj|hlcg9y zH$NxSd#bM|8bL$fq|t%u*E0-so|;PAje-heoNQ9R0OYUhHX?E6Lou@gp5vLthsqmm zN`@OSZFJ0887_E%#$ZEw0FyMg>=*2&WsDwJD)Osxb$o z<4T{bq|qn+G=MCZFEP#T*ESSDG8%f^P;DFMR|udr#1)WI*!_a#PV?!psEyk?9sZ%n zN|eL5P-M1-!>SWIBa7+-0N*!JwD8jpO>g?lm^;#sW=%TrZ5a+rCsi6pI_N83mG!<# zf0f&^Il)){lLu?JLi|G^16!wny;Wrt_ZY*e*BrifnWq$noWlqU?o@fWV)CrRI9La5 zwCi;sVaaF@B13T)<$!^;*Esq1^w(GW%;gD=mOImlmt&cni0S#XyMigh6Qavt9q*@3 z^H!xL=M`QTpo;}RTbN6AWZ^aU(038}%MtMvw4*hD&4Ky^fd8w8)zn(K^m!Re0oJ@+ zcl8FlL^f4E$Kq0aKIK68)Qvu@>k4LHf5F@6UTd$~Sl%k*$Ui)kUfUQj@3s_ZQ!Lz&7j6-LcX3PEA85!>4F>$Bmb2RMi#|O{`s_nZ z@&2V^JU>#tyc_M)h1CjdkrlChb&lfxNzo#ap0gOPuR;m>=>C@#qjNDKc~1y2<6Rmk zR}N(4{}q~Q{W&q?zojHnf z>CYurzth2Z3L4sH zj+?VRh#;Yv`JKzrk#PXCh7RZ6*~Fr^s?fc?+um9IA~UMDnLG3j`D{_;@pFzWm1jtf z@3pQ%Yk)3jaTiN!$cU=IO4!V zDXWXd!rcenWklICz9pC7Tn~UF@nilp40}!lQjFn7Q#Xo|fW9k^0?lizE|ee#z>yEz?an`N#abAu@s zogsOYwqC86P`1KX(xL5HD2)^?dt|p|9dCAS7@N=x`6+81r8C~N-wWTC_vnU2yy?M) z&9Y?nHUBE^dp0&_=gR_PxBvcD*UBDDr~C7`l?H3e_6I)3+(~s)(wxp9Tz$uH7P{#vj;P%ENEd6`trWErNMp zYu7V$<&iQ#pn8|pcar4kF~(PWmou+$-sHTI&eaTBBTsI2UzVx$g6^?h2>P6`X$m=9 zV5PM5-{cP7JK%i0ImP(&Btqlynf=F(VL4IcykAe`9GYCjK6_*H4@l$`etKq7=AZN= z+e?r}_(yD6fUxF!6t1E+?DaiACI+HchGuPeqaOmP8%fIw3`K~7P@RPZPM2r6X?|9P zLPO|i2{E&4|5};;=aAK4dH*LwDiRXXt5>gn>(<}8_5ahl)lI8h7%~~6)$VjBjgvi9 zY1o$}*2h_*QEWZkJV2L}HCL~?L;Nb7i+Qox_6$kq#`%MKYfuxJylm|Uc&8Q#y%WRq z_Db(pX2T?~E8DL@K&Bu1zynhA3lzxZN@i$wCPa1Hap>GRhi8B{D_(9Ydp09x7+)dMzoH(?#RU(G4F)m<-hx63AB(e*xZhHsi z>0HD>clX271K^PE;(^FHeZHYboko_)oRJ}qTgi3tDK%18P%oH;RSixhGZHm1+;$Y*CXBT+^9 z+^Zo-cP}X-IqWVCVnykG<-}257v+U1P+=R!e05UKNS2UKiA~mvQ`b*`;2|faTInFa zNt3WPS50?7=`l<)Z>v{L)|!}}NO$Q3QDq5H{iKfn{+NzR+y;MU6`;IOPZJd_t!@sC z)!+q*1u{8+BD{I?Ocm|O(#%Ah3}(`cd>c56LJ*)dM{sT`Z-coQa8zR_1Geu9iI>db zg%mgWUDGGEF5rQo5ES>_*d8}thVlVv4Lyl^C0JgKe-!>Kq*&Z?tD@g&mJYOpLT^(K zilyJHw1oCq!#eDcnX|_#(sN*GI(>>YF%y<+HFxssjHOqDu+ks03#gFB$5^kxKPz|% zZQ9(Uq$YZ~j9pl!nSMtfc?wHW@ZdErAbx3uoD4RvqFAX*i`z|J1v~zb8 z4C}+ubmS@R*OnyS~-CHB9xN}I+Jlh%tRtg{SUclL|=u=#J32@{@9)9Zr*`(w87 zJ+>u|T3N1Dt}Zzc{8}TDajjMzQMb;dxEDVj2RCw40Kj^Af=Zv@@`Y7es_{i+sWS39 z*WymYlInrYe7hkBgc+S5hfm!rp zsHxN!wUOkhV%ra7oIPJ0hSaBQl5r?@uT=?d-y@08w7oPa3wX~@iV}-Wgb97EYwfPT zg-en?8x>(?<_$eb=6D2<)}3!+>Z*4R4^nkdDGIX#o9AyX-r>jQg+ zI4(_9f5Wb!&)HT5Nbns4?&MWD&Le9-?9gmEHQJjDd>{6-?8Lhs5t-J8^6 zFqq-UW(o!f3867+&~A-ND&%s4-8e=jpn+{sXbzm#z#7|={t`{Wm8{!bg($l94J0wTuV>dYd{*O-Wq@2!wWN;*J=N1+Mt+xNf(uR7HziOKCH_t% zA21O>5gVcwh#Of1P@@c@i4Aj!tV;}k(@GN)MuTFKqsK06L>?7Xu4Wi*)w78kLo>5E z92LH1mJ}hWh2xwu*5yACAdpzQ{7p#>>Nb9ArC?GTL#0QK<-SH3 zn1B%`F}gERz3Qi7T{HBYAgOW6#?rQBp_$-(G98phsy7Uf}Fb#Xe`o zVO#Nj7*)qR^+ssPO9Fn91~F;(@;VY+28JJ32K+X))7c*Rz2vAK4BhD~{LDRku-nq1 z*L_;hPO|%A`%Zp;)XH`?Tf(!S!m77bzZC5wPREHhJRHUpFp1duye9Y^Bef1J+1mBs zw4A@FzF0fWsqKUx##q0&c0x!wSk`c+s@A4(fwbxC92W^m7p51B%^Txal*NiLSF>1j z-mZO?0${G^sw2|i&AtGv4Hf?PbYa4JVVEHy>-uLBsx4VP`ogj16HD2T(8!F zMsC+OnnQ@b{ubS)+m0@U`}6a~15dJ7=GA?hEG!5E&;1AsS}DNrQVZxlQ3 zOAQotL{F7ISfMt)&!{N)1X-o=dsH0$lXoh%@@-TAsrp+KQkORDiqEc0vKh||UvmG| zkQD7-hNLW1k01ZpRON%DeEIU_yLaz?)A2VQ|7JS=cUOp*)UqoJ6~gIV5QNeW`omdb z@pSX9UpGe!dDDplrN;|?6)0D>Ww7^(Rh_JNV|pVqj2#~ZwxvK`3KRv zRn3eR^yJ9zIdbe2Lv_DIGTzYc%}CqAnZWKB!wb!xNEpN%%tx^H=tWs*Bh|?|8&D*g z*7Le=dqUG-Lye2i0N&P$a2Kk}gk2sz?K=^R{w&~pzB+^&Y&(3{xqrH6`4dC8Me91E zXA;YM_bT{}?^=bXS-`Nk7bJmOR1lAoRj!<8M;T;IpI(1ub(qhW8& z#)=5yr6<4?QL%I2ev&jQXX-qK0e-#bX1Z}qVS4$c+H|_7QiHkMz0t&Fr!mq57?qfSPZR)K(aAIAm$BeVYKBSM3`|w zuUBueCYblBHKeai!#bh3Ic=|G_I%2^{Pw8fOPzQm#JqkiC(x#>D2||{^et6*N!1MR ztWo36pk`sSKFR|zuO?6#N{|+1KGZ!bOf9RuWVJf1+@8TNFFzpbFYQ1zocoZ0(dW+G z^m+#Mdl$tMvx@H5g&B*j?Kb?NmNR3*%6hiw+k>AsEB&SQT~Rf5ExePrN13Ep^T&gf z?fl>Lg=*dRM?$FvSOy}dZ`t!3kxHuDK0D9DC#i!O&!)b{TR4`I6f|wmBtA!*89u1D zw(huy9bi3+b^8K|o68)LR9j?v3{72{z=US5FyMLNtthghC#`ya0mQ)*K1Fd3N$C@( zuG`FbX0Oq>B2va!Q=8vy>V`$dZ^vcJ6KzqqdcE5z7?sc4?OFzKgpwiw2@cwDWks?06xQ{s$IhaWr3 zm!9DUyiK)!?N5CB{^byr@Ez?#qgI$yO1|i?R*2~Td>DM#wZ*`|c>n(WZw&s%;NJ;@ zR9e}8Y|`SXmIeH=N$byQyFGWMv@Q$y=pS@?_2prc7AF)(thZ`QIA11HIunIs`xivQ zlj(g}HN8@aro9^x>RT(d3Ny#F`Lr6wfodCgLjZm4zEp$LN!U+Ft*u0}*Av6140VU1 zZ9!OkqQ!Mb(&bU)%s~wG$A*w3w&0H9dRF*gn2XpiQSgWf`p)?HIYaOphVrZ*0L}&S7V1GDS|xOS#2-s8NjxXMRP{j7;nMTx zqkO3^`%2y=J^+VGSwG0ux?5j?*lP+ih)i76AQU6?{CyY!$i*Ot30h4S{Ge&@ zL=_!Z^>`yM^8#TV^n~>YArq{1#?Tk>G~YVeIq; zKVUOwfPoVR+PuprN-AT1j`k6+Em#Z=LmP9e=O9 z-9UW@)GRp5STN(4&In!~MJcXqf@<0{ZaAeeRi4d+F*NVv+%UHEsPr*4-O=sP)-4Ni z?AN{Q3qSl}#pGd~aaD2lK^|LI%c2v1D9og*MgF;6JUMfnbq@hBqN0ob$#g{@vu@s2 zBAbNzNgNkeDq9C#S4P+Bh<3bgE5w6>D0ImKKw*7>I^L|LB~5JyeG#mmP!z@_6i z2*C0VsTW{ng=Haso``QSb2UO3%CZ(~g5nSvqrR};oF3f&ZSx&m>e@Jk-t>B_s1lH~ zfl-J~vZ4r^B;s(qIF8m;#5uf z`b4;U?chRl{c~@AEvCa2)@X7eSfibNbuTd9_?>oxc*A&!VLvpJ;$oky1>ygKSJ2_A zWM!yD*_%V)CSreaD6upRF?M-Qy)s1dUFbglo^nms=hqvxUp~*<5|%zIlGf?@qj5{V z55RmbsULW&U8iTqDfSL4=#=B#uV8{2iWSB8z0NDav=y~E!IaRdl@Jzru6NC#9b)jh}yVUU0?IoQ^d&SaOXQKD1gq1w6TuqRztE3f*uC!bDlj472Gn z%MBDnp%n|2mj>k}od=s2JVI%pF3d!CTF=VD+1$!rQq};AfT0%PqW*6U*W0PNUUU$@ z!UbMPvfy?YeR0#Z+i+UjKAlN9s#vI1g&Y$kreY9*%_v%A!fjnN$(p_gokgE5sacdJ zfY!nBzgl~3%p}`{thvqFR341tGZ&xCNJA^9v|-S&vtG@E=7Yz(1}*0`^dDlMlDsl* zdvyW%20*(fIBLWo=l$M@UFdcM!*iZ2cOucmt#BY0Qnj|cqsN^*{>J2Ol{ekx3 z6c3d*Qu>T`1gz$-GtjHNF#**x^g@$SS(~ablL5%};jGToeB)}4l$l(aYj7c780br0 zUTF5gc$hM&EiJYAVvz4Yl=p?ss2$-bLTA>snk44Jf#Ujy@~$uBzfb4pFfm#H$&n{= zv)|nM}aw6VDfCgwyO)pyIJ<;>kd4*$XnSfC&&TUc-9ve& z3efZOV+l$3Z(n|@FUR<%`n@lqHX1_@=5sT6q{VmPS3GlKHxrnNxj%hmpz^RR&u<&xYrBfXhazUT;0MlIQkSp|)=Y zHx0lM>{{d#SWv$yYE5^&DI9yvV=j`2;%5FOa(`2&AjqcI43HhB@g5YF=4O$T1fF>h zUI0|QumD5prp!wc7Q!GUW&6GKkhE*Cera1po@F`1V@Y&p zF~A;IL+gXi!qM_Ltd5ftrCo#2OwNmaVFiTzi=1K;4MelU*b zOENLCD3^Enp?5mqG0L=`@!Uxse_cWUI}Z&zGm zqP`OKz$3XQrdGdjQh_KU1m(8!Vm_xy<|lnxH(N_I@trnGG!iyiPcSP8rARjpN*#`w zNex}|cF^gfF#1ZiRRH7~8Q+LE)ag-4@wZbiiVH?*$WQUc49ou%Jvf~o_N_;K%P?F# zl~yO6$<;hKQUjMRKi5LdLb(tpf!YxP$-y@S<92R?Gcv@%n|ZZ^GobLAh3Nul{c{UC zXbxg~t+4A_x;S6jJgm5Q2#27s*kx2hzpT>)vKunjnYs!Y2}I4S99NoUtbtq5XV;gs zr9c}d&$ss)n~v!ivM!TAwzYd+;pSCY^69oM2&kp)kC)V12hDMuuS#2wrTZBmZwY1( z>+i$(7}{_vZ;m=qL>lRP&cphdd-0k1S^M-rge?7G3o`ZtuNJjV`gp^8?T4^qZwiNP zFf$8AjL58uU;^|5G-JGcd!Qd5Rkf;vKQA(Hl&Oz3o>hF+9Vnmjca+ne!tj(!n?6+u zKkqdn@8ImUz#w!Ev5(i{at68DHr9eYHE%hGy(jUxyMr$@FKa^KuuE9v(9NZPJe%58 zL~NmZy>B|kRDEEWnlg|8y(S=x}*AtOHMi;^lAR>4G(f6s`DDt7e2N1z~{g@5U zA+D7RgMd9hbhHN$RTn74KkS9m?@Xxawgc$gYsO}3;#UUc9Jp-rJ-l<#nLId-R-n=3 z(Ip-{d+UR?BGFKH-m`@X(3gcR0w?GAMFm7KGmqfuIbclyxRL60;+v1Jia(SDv z$FkU!+H#CYzf%wT>Jxisisc)HCz&n&gTtRYib-9wYRW%#>Aw-B6cB&}c%J{x}J)ubk=vH^uj*{2U{Eo}w8c zDIAm+DLpe`5U(Q81CL(es4~?2N{mO5pcZ$&n5?6Omyq}tEp^h%EUo8l8WxB)O<23Z zoGN8xi(2n?{nGtF9{{JNQ=*sDJ25nh8E>ZHz3B!0T3 z6px1oF2dx|n8~+k?a2qfNxhzMN8~*$sukyx&EOYj4Ten>_yn2jDAwbMZWpx@Z$sRE zAaU-3`?k#>7Ik!%MP1A1%MkfnO`RmWL(PvjWkoF>j9d?5kd{L!9^toNpUK^MK2&MvCZFmxW zz086UeUsMW?%7z|w%E+)_7hInc-&*y0fb_9cjgJP(I}#doLhXyDbg;o`mT?akuU)61+!nBN>;WW+qi>gIlKW9EyVq5M?n zX)7|c*N+^h6w4^S2a+SevCb)hrZ;ijy8TgP!JW%{$bG1QLG+p1hEk4Q(0E?vz+Z^eCMn;UvJ+>+!$V4`sqQo zF+XCcV_t4^1(At+xUkH2Mhyr@y?PkKlq4~04ixjyZ4Al^`K!>mv5$@xMCQoLJ&a+p zp}J2V2L04~GyJyj36MM2!x+Z;VbE{S{XMlax?MI(z4YrkAe&s(D$VT#JWi?kV(-?? zd0>$XVS7M`xNKwkgWg<^FMlOz;8->w^-E9*eMw65UdrgsaYdXC5Hub- zIPwx65)q0`5;-wlq!=L)_@LtW4K@tLKZ2i+#3EtWO?=@4HpVb^vBgK`|1baqp@_kI9 z2r5+i0?ebE_}a;BzISamapAb_8@z;vHK7d|$#nwC*e+c4r;r-RQ0C74){M7+uy(H3MnENDbK4VJ}L|>rr!+g5@ zM6CNGCe?7j4k{snzo=TeJjN3$OnR+D(f(F`KM-m4;fkuLmT3L67m1+km;cBzD<#(F zpDp(OG;Pkm47h$@9sl;B{F{9!A9(pUwK@MvUH+FAlavqlR1>AIU}4W>lCq{MwfnsO z>FOBX9N0IpJ5{f8fX^R(!MNCLe2?^;-sNzjEvS-A-oudv4vr#Gh-BcQTkBO|Hnah| zo;(= zVz2tZ4myyCWlQL}e=c}b$6=Q>5%{v>`~ zq#;w0o5QZq!0uSv7aikEq0H+AWa3Pis_+QZ?3W3lj5&$$$j152*r@X5Zl4(U$6L7{ zC86)&Hg9QD^WwZY>fgi)9=T8?Iw@}DCgST$rHAP>D6glAQ&CVUP~~hTrt6?zY_mv&QXPC{jZu)$)v>#N&U6|Cuy+9Bc_OOZh*JcjByD4k7JE#StwVZJj> zv4-1P)4KAa6P2lEn01n=F|i?>`9~^rhq>(PxolY*5-uSP?4>i+0Sqnp=CE~D#cIC| zV4?MU++c4O6==Lhm_dhkL4iJD*ChJ4-*UM4ep^g~S!B6HBxf(~!uG z)rUd6+J2*<2%Vkdk&hc5OM?XNR;L4s)QoH+nnT6p-JGGttYb1?1l~+>gqE;PYo;;2 znPCSBaLjTLgmbu!I?X#EM%*h)Xnwi85a3)iz9C}k)j+j&TnvzSdLe74IqNcif*`E> z700aO0{33b%vrKj*gqW)@n3bC4I2i-$Y z8i(dny7C9^XN^meG)1lPj%A`qw|Y<5XoN)*KRZ+KXFpT!ZhBjB=*T<=L^ZsudOaGW z%hsr$-<&U!oDuHN#TD@dL-V+Zw0YG*oE`Z-?&YVyvY7mLj69k@{2ARle)~E9_H+L4 z_&NV?>CXScsu@K!YoH;gM6=p5w>k5Mlj&=N%kqqMnyTJ>lLr>!nU>>+Wl#`CpCcs2 zVx?1_M8t&7wPLh4Db`@n%7M)^2Oxxs#)wa|l^-zzV46{N-0rZ4L!Xy8AM9pZV=NMD z&+hMM)g#|?&TF3Uz&a+;@9_u^?SKQ;B;NS*7pL7l+Nxd98{WlTQy}7E4m!tYd}z{f zLQzWOsdTr%&sG<&Vtk z@;2~|#uQ$Fo|G9@5RpYUez2~q8deC^$k}ThqEtz&FtRnu)Ce-()fL$f1>{Juv zp|9p?zC^vaOUaC-Lq9bP=U923_eoM7*EkN9p(i$mxVdOiWW}*Nqh|)p9W{XnG zw9{lwT&*q7IIXpnCqKoQRkbj~Wzf|)zELq@11LTks+Q^l-AYdOlNQU%4&GMJ%f+w0 zQi_Ved}|ydN!2hVs((BMDg=u6Qh!qjx-J6wJ!{xVfhW=#iXaln61KpOaSyJODWi1; z#W+&inE7=~KX*V4qr96Xe15$-k?q$!blT4{)vlR7rsJ&+RG=?r9(=ab?=&g)*=)Utcf5S%(B0$oy;k00&PGho2qxZ zBPFlu)n@^`d%py9-YvwSzPRm~PmRhwh(c?5f9#JhL~&xl2c$eLQ}lK_njnex++UMA zFgc%c6{5Xd485nn+DK()x;_EfvfSL%Gjra)=*74>_eHh!mcALAxR<(z%RQ1|{kZb> z8^sHXFGwO4AH6sZ$uX?do~8!0O!7#$Dna!2P9<6{_MoEgMCTTS*+0cN49Zh7pdEZI z;=`FJ^=D$&e`(m=f;alwhqICNv3-%q)8n#Wl~=cLU% z1jf1?=sdQ4^uj!ht5mVs=jfWUH+gBkRUU)f1ng2i+8Grq9CbTsGxo!8T7DvYdT+fq zaM6p&g6F`j&G8OEtl_qAkzKOZjE>K@Z=qU5|{jj)j&~ zpA4$-c4SAeF_oa9ozbGpWv&Au>4Amm$^R zAd{g)e>)?RayMb%jFnlZ6vEs_^EonoygN36&Nu{aCIqeXi6S^kQ40LDo?@iS)m zMl;jB5?8K>haZcN7pmm1!Z~5At7ek7CWY+fnvyCbNK5HFEfuhyt^%H>GQ({(H;u7l zttk+8UI7_ohzaw+0WYtla1)X9@o2JFSu9>hc~I5I(1Pa%tpx6W+nJ)suqQL_gl-3_^eIG5sEoW z7X}GKc^46zIb+M)D;dlmxFi7&cU-X4qZR@M54+Zym0fW6yV}Om!AU{BlU|Pa44Os> z^33C5^72=#4GJPe)eS3TYE?ZX1esNXs3(n8Lpgp#HB$|&jB#U}aB%f6bHg2u@h{Qy zP7{{xcQyS+8-%rpIs04<4u4f|T((6=I2wzsZ!#BmtLvf%w@WBi!zEC9w(0FmAlrg- zj~eP7S3s)rao*-kXX)Jd|P zI^S-rF zMUhC?*|4iB^{sw_9gS!$cC1Q_>+&1@br@v@Bvigpdn4yBi3tCWT>VeYoiDn(!k_;0 z($Sxcv%f1{3`awrDGCc-Kc~!3wCjy2l#s^HDtfKI9g_=8o1xChmUab$5gp{+eyeMVUG)f;+(*AYdK?54* zE8FHkJA$&dj&qB`A1_2?8QUiC39~zpYx#pX0;AGFL3>YcmO|~J75D&nHX;S^9tP;CM!bMo>$*TTN1B}D(?|mm+qNf zw}P}U(Ixm5^ z^5u+{6{ZqvdEsF;7-~wIoG*+j5a`XR)A!4n{(M26dSrL1PAu<%^tTFo>9qU@K5_!Hx z^EyTlJs{6eSjEUlh7VDf7yX54fvW)!hOu56o4N&?r%Y96*A397gCsoWj0+`=56LfX(n`f|kmjK6cTBKv;%GNF zwpkdc1mENbsUp62cKK!O{UFOB_})!622-sKv?;a=T`sUX>>yQ$Q0$-?lRfNNYk0UW z;g;z;=@+TAV2e7z)MjsEC73_1m8zU&ALF%kXU`<=OXmP+5_p_}v?cD2Mp1-HN~a9t zOB}~_(g!}wSgPI?ck03j&gXdAUs)pN%NCkA7RYa8i+kKeiq8iFWp+6`zL5j!hUsB_ zwVhrHnNF+j%wG0zt^0WnSUj_4{Tz^u$ZaW?gh?r#&e5vg+fP%*PNf*8=Ki%7=s>Mf zpI&vN+?0F@Q*MoC_fYPLcHmd;DP7>VE`)QzqK5o=n`HVFl_WvZJb=HXrf35z70 z%}HzN?*zQdf$oAQ2ep7^_Z_TvEqfQ?i_L*2eYGvYcOr^+uTNXz)-dQE&&@qPs`$7} z`P%78z~?8C7ayNtzZS=q9e)Pm`m`n*BTVF=^D?`T=deg#gNz+)u(x zoSI^SD^FKHilO%y#nm#*5f89>4dIXtBZ@~%c9I>e z1Z7SES%&q5>ts55zSLNp(>d~5!uM+~Q-JsLEz@|P-bux5Uu$!-*ielcnmkk%>AaZO z6>-X(SKntgQ*@rwEoEgI%)HL$TX6*!J%33110f)OX)*C;72qrgsP`HS1$Mpz=MZLV z(3Nt7HR#F+kYSMW=xYu73YxZuJ>I8p+>7&CrD404W5ieVH9Z#T^tH2tmn8)?gLKe_ z3C^9p$|Z3b%ld??z+(x%npSpb0TF;7{`9ggOQ=A&&GQ~N#d z++p>mJa1|1x}DbhHdb2x@(zqIyL8w zL7XiQ(11cxBi*oM6o1u_#__HFPbB(1yAGKzcXXp~kp<5B*ek7qCnSxgR`FNL?Dxr~O| zYFADN2?45?6)P|w&?~s#v8<5HWi-1;b&)I6>Ms- z?`0=>*G8}|Qy%Z8+7!OfR`=va*N#~i{@{W8Oa72P7#DxQ_>*@{abqGicRRIXUIKxO z{?kncYYuIbM1Yl=!T+r`;no!8*WJj=h?=09u$o5KP0pWuCvF~=sYZ>e9mgp}b zWRB0C$oy!L=ro@aZ=rs)_3xi6FAAbsBZ zS7Ipu+`-r1JxAuyR98Sm1^tdZg17Hc{WmH9<6zu>7p|dyKAVHz`@g?)=>MBJ^na)Q zUpJ%HKX2;LqDt%dAE`qcZzlr@1I?C$-bK1h3e%_<!Y<;de)z-7QBAX^5Zn4u=5i?t-BaUYWFG^ zBq&$BGl^I4Ra;M&6l5>3v}Quy$YkrSr%JNFR?7pr%$RKi;N3~(Wh32joB49BgJN=g zyg*dhw7k*8ULV?j&4)mJPTRU>Tr7xkmzNV<+lZF*n z23LR?3mtUQi|Xd6HcNm@@>SOLtN&Mf=NZ-npY>@4rGo;B6h(S3ib@Gh>77SVX#&y& zLK8$#RdzbxM>OuL0ELmt(@1#5Dtg};v8hpoXxbk%5&TqcBfZi*B;K>$O75Uu z)>`~n%cjHG+1K^+H;xR13grzGG4KiNvOrsQc+Z_K;Q)|Z&WhXkD(3do^Wq+Xk>{Hq zmL_>Nt5_==+y?CeOL+V5ihkKf>U<~P?U$x>+UmY(CR_H+IKX(f-s~^aUgcA+vN3)0 zDDj?AhD^6_wz?BleT6s~H^MMOg4AA{6AV+D6~K8*vfbr|+}gCq2bfngZSt+vSpZ^! zZysbq!-b2Rr11O=O!~8a!2WAjeMljt6cVf2o#GRXsAQ5(uJlAAb#qjoqwrqqpC7!f z-A_ab5eGU2NXIFM}UjuWH=0P#kUCD216JokaX% zgqAu@nu+?;4OM9@!~!Q z{tt?IroTVv75}SO{#Q!MoOy<5X=%?n4Cfq%e<_FIcS)IlJI~Ow2No}|IlJttoO|4v zp_h~E4BxZl96VDOn5@o-u)OtTR{1WV!&2ownlCnjVV?1Zo9yHPXRK;`ZES4ZYN%D5 z%k{Lpgupjyw22ZL_#1CDjiqRlUqsJC%*-^d;nM7*?5)yhbMQPVZslDJQ6!DUFEiZ$ zN`+CfNNx^AEjaghmM?*y(fBQH>}ouDjv-%0lmJB$#Er>kG4m-{bv`9gJiN#(b&wk$ zpBV_W&dfnR0OlwAFfSDotbx$Egibr5Se1su8*-U*1ey>+!WCx?I~OJjU80#G02Fbl2E!9 z6S+H*`Cfa8F#21qcg+wjkB~&)8_k3Lfp?UXnUf_Xo?g0Mpw0t_nDu9eI&nebQu-;! z!`?b+ib-`(v!!*aKIBhbJF-jfP6oIlTy7kD76yq+`&38xAA{;+YL5MySR^HaOBLk+ z&h6_pNL=f^{jZz74h_CLypMEGzI`!YH`&5UeD>RKvArVaIu#>oafmCl_B(Qi^r6+4 zo0_T*wp8c+PfR9aIcx}| zL@^c{r#e{(iP^mCZDuURLvHg{ujaaSlrC%PypdLJdw&wTeb2_&BAlBo4VC82W1t6B zXG^cEKg6ZE)r@ePdG6KYvRFFswgz(-DN^GMX`qEkzRqcUIiU;~yN}Vs2E`cxIzEf} zv~BA&-Y1a$$q%wNAL;^PcyGX^0VzT%G7(EUf>o$FSe1Yb496Eyi-_7t8^HE05; zL`x|ax?eR+%JsOmQ9@vM+r8sg%DYJESzbXa@GKi0F&-IHpXV4twz?T(yeqodTYrLk z*uQ3!d^wqJfb~91Hxc`!ynfTYPvvAqxbHIY_pKp)TW!QpYqa{VrwN4TzJt=eM(kK| zzA$@_V>-@b{01m<6LlOV?7TFcqLFbuZ=&I6Rho`?x?>md4 zoLhy?(lw`_Yl-E}C(C&pehc|65I+}f4z?*srN~zw zOVTr5c==dUt_x?Az;rkl%tNp;XK*1b4jf)w@)l>joD1k+ zTu#qTR)Zp%LYP+ys|Y?q<;ZBva+y1W;7sL^io;6PrWD0svXmb2qgxFmsE9jgEUP-OmCQ!N zwf)qrXQ`2P1e)I=<3Y96!70k;)`iP0iEDXO5m`_zP;0uPbEfb9eEEU zt9kso>yeA}PA4O4RCe&4rcw8BR{5yPZ!-GSRYOWvQB}iiH~fT0m_a8wXI7!ioH2%6 z*&OWEiq)z?iy*)9?-YZ6AOF@Wq}i=~_s1@+NcQwPJ34D-;jT#5EG6aSow@PHa`Wl; zD7$QNbnk2qU5))Php8>mtXm71BvxhL{-scG8 zPIk8p-Ev~Q7S10%HxqFeOm;o={;=KPd&!ofSydTy=M3nP+W{n8oZS6e?8>8}yf{@% zDpxd{3P1NF31NF1FMVJ=x3{63(R|874|49bTT<%W2}{LzZt(K^dY%k>uoO?`$XFN7 z+c_(2!NZ4;R+!;J0eqb8$qan*F`N}+2f4a2T$(W6<)9e^A&;o zG=T4NbpWh|g~?zbteK{q0;W*Wdz4aK!yIe}r(DIr0)q)yhm@8|gN+hE2Sh>jnYR9T-V-j!r_ z4#x>)cJ<|qXJER^BQv_&n`JV3nh%Rgzl_c}miB%dTuJXkUCd1H-)C^z8KCqSah84_UA&-@+K;N56(&4_Tav;OR)U^ku>=PDk?2cjiXQfb$?xk0{32 zps22?3H zFMQM*HHCcvwYLU;5oK67e=$WGh93mYh2kM25G0oEY&U}$ET~C76Mj>yVU|l=h>l?I ztKi@>Vl~H;vrqs@1Yf1^Ah}b-pw4k|xd4TOaKR|usPKpHAjkss+2_D_YRECpl74) zvvJ0qKTrwvWOQF}(7MypF`$+4rDghOdT+yKNqS%HMeX$da@rH{Knc5(apbC)Q`sOy z>egnvn7rfekfANL!NXrr-9SxEeeMuCPf`Dw6!kv@*F1L!{h>q1FoZ|N+BobwPd-OD zzsTM-B_T1j>+dC+8LgsiCqg050BdB{F%oi5tbFes9n8i}946+)sF0B7CWr`xaVB7m z?DG{3%?k75pKFXwr-(hij*C&CAulj8>IyB0eTwXayfoDt!9~6%7}&ZyL5r_tQLW-{ zn)uOlIPDFUX+gT&{&4n&HxsQP^{KKS2**M10owoAyiUQU?`OC(;+ZjS{hV5CApo(IJNTfGoX9Xt&fSCa-m+9@|M5=fMr5pP(pB3=<7XcS8~ZRNEs69aZuAO_VPQOcn?P*n9gwn|a_E=-bwnyYtn#=X1SvEC zcoznx_$1_bqI{A|&5w~O<*mh=-$mh3#}QIrbcB+k45$~^8jjKSU)pL-K!}cQCf|X+ z8<{_Mz8T27{c>IC>=kpqp+DbJ{K+lFKh%YOhj_NtW)M0T2>pRTNT6dB9HpEjVP!7K z@MI`lYEKOu8*>sm8=dWHooB?zo|Y1R}zvnD^^<>~M) zYk~!X)XNB)WCkvMW2h8P*$Mpyr{@Nj;?6Y1dK6~8ow0wMS=7j6_r#S3yWkkWiZlg3 zrz_6NdA^G0%%i6TKZ7_jsq;I7;VSri!+2w;QciR`-do-uYYQd7qksYyskz7^&k$C3 zXrc*LpcqsyGAxJ))Vt(hkg)_PNi9|J$yDf~Fe#}*nH|FXn|lG~rL?zLOlsPA+DfbY z!YM^6L!*vkYYmiPrFB^V6F7X~WqnEG7iQ3M)GmhH1)a7A1EZ#qKn_&Zu8A<3QaZTo z$t4IV14%@tydG`WXiiPLWDk6ynUeMs(59pV5>&x z7$Dkshm`kq??kwaec$p4Ravj}vDbDJ^;e<$WlHo>+XIhK&wmV(2m;>(8fxC!8m9Ho zcIwaxGR+xfb8++tv7j@yc za+?t}SltZ+c+|^#ANMxa-aLBN)W;$-i1I`q4mDf}keSI7of^Qm6Z6R~teDFGG{w3g ztoJ9=Y5Whgqjk~43&%<)d&|-;{zomXSEjbNPEF+fcBVv22|pYA0%lL8V^4|fT+sNcc#6w-Z1u5*t*p{etMx=*Qf!tNvc>Ilcw? z3dc&t?*-fFsjLj8$l&=Q;tkVO)s?Nw8LxtArKocMMv3s^m%L#+JMF%D_3HTz+xZRK zzwCzX@6xXR?W93DKzfnA92NU0GTJ~Qou`(emgR=2Vb@9zlJ!L>NI0M08|1c%X36ma zD~m1FS-HuvUVrgidPZ{2z;uXl)bE}Me}BC7nu9#qMiA^q8X~yyw+G37I zq17ZnD5AWAf*lb>1ab%qq0pRG=nw&<6&6kMk3pL%$j*jetZGi>a5UC20ux@hS`LXn zc8WyKNQvakSAf=(Vq0oNiYEXj2&GMueEaHYnh?QQyiT_xYHttZ1V2GbB5H>Tl+HCt z!q4xNewvp~Z}VtOzgv;5&@+S{r|T)}+#o4g{S-B}wAM&#^pn05C*I>)_0wW}sgeeg zUKS;O++EQpApiUxhMq*$t(`|ztExgpH{*W4qCvN4yDrMnq)*!=<4w1&RY-M*#Ocr2 zA=&1Jie{;lhV2CNQ{OwE96r~(`Tgd!e3~STUnwU zoP1#em)P~vv0mUguBa=_!mrO@(H0x17-!$2Qc8Kj%}A+Jx3!(ex9fK*Et~Y$F>l6Y z{f7er==Nt?Gkp&QfH88j(}bAhhk zm$WD9B7ITK_T7HamGO)d4mAk5UYR;W1KXtslJtbf{Xw*s*iU?t9?`6=%Wy=VUC=Gc zS(pFzKI)!@x(Kgg{CH`IxUU6~W@szx(Z+}C|5FG`iHqdFF(H4Y0MyxC!Z{Q2e46=B zpJx8<9r?Qelqu%-(2ai=Rgn0h$KaVNh)?giT~aW|Z=5RMZb!J4ji=ev(6n{hX@vBrZS0OQrsCCZIX3FiRW2Zd&wyGAdGxXH~+-%F+OfA)XaR{W%tIjL(w9@3Av^X%$$g3c(k*vk1*lfEE=;`h%f4 zxoem0N^2~y3F=e=_XNrsDZVelQ%P9tO{wF+f>5>VEYsz+mY{($2_w+Q8g!ruZ7nwx zoL$$)gIcW^0)ti&V=X8+e057humYlv;zc!&eP==v<0%E3I_r;18>TsAM4EdJLGTv5 zg0%2EnxD}s^sxbF{-36+h{(Cn}x101olsFEJGM#L9{}!bE z;H!}NN!h4zpQiX19nek0_uEaK+gfrc<~O6BG1}#XF@}ZA8s*pBPdvxz35`3GPTw=! z(RX1DHA7)mQ694#^b@-={`%+oo?196-VFX8k#f)wEaY(@8-sUpl;^wxP|=Gb`?m~ zF9j>ei1wS+G(^82zmhE%JXlLONq@yT>Yq|>wU2g~>~l%i$X(a9Js~)#pAt#lB_ikq zkZ^Tfps$l6VOa5}X?uPthq#%XfCyxec}k)m+)NGV4boU*Bh!^oI0!2X&LL)_psZ84 zxMur~&zhb}lPsSBZTl{+ul&z!$`NB&)+6~{$lqW`)D{AI4h&*rb^ zee-<&`X}eFe>NfYcVCyk@0$~tYS;+kMQivi&6<+2%3oUzBs|Z}p~5p5Y^hu>o5x|s zRW127g7!;jcCW%G zSt;hXd@pB>@!T_(axoMY4P4Hv%PMxrX`ownfXz1gEI>MPu?{MEP-_AE3cz=vh$iIO z7BCx!Ev;oxP9Lmdc-FmAeIqP_uAT~fTv~>InZ8m}XiT|YwiqI~?t>Wytu>MkV3x9K zH4+gyq%2$;<(G&Z?$nVfmXx86IJ!j}?_#@Gn=a_*t*NhUsk(mR1U0PtoxJQRZ(?@2 ziD>7u(ps$)tc(=yIn9@O-9oF`AZ!Up(cDIf7>t+IT=5i|>pwWk*csTCbGp|hBVoMN zr}zGO$>6hl)U#bu-9qj|njj~!USg67_?YbLOwLjJh(_@-i$P(|aZ0GL7~#9=_tpN- zJod7JXNo;jp?Q@?x~RX_DS zG^juIKGFYrilcuhMg7W?^qc-qbmgb*`>*7#D6#FcK6xm+DWY_J$DBl}x_R)xE|B#S z6Pe;cJ#G9eu6F6^Zn`1`$+WWfA}efEOyaGgU1cGHZS16H|EI9K{mQBnN9;Q~Neb-4 zQEn<)gDdexO_XBg{<@L5*9!l$&VnkfbHegD1o9jL`Tqk1a;);d6P9N&zm^Lq_Zn86 zv*O8&!n;SoM&?}kb%WugjR)YUw^3XWjf-ft8_{=|FIh*ht)}Y5WK!Rs3)2uun^jc- zkD4mVQOwPS8SsofPUO?+`Yp+bgb9~mGAId25%Lg!Y3>kTPn*KSem1qWNDkxi98*?W z$TGmD@!BzLrp?D_9(9dol1mxQek9{RDoB&z!1BdX>>A-?Z#EmnXFN62z^7yb^Xwzz zMSz?|l7L`5BtLgVActUFaAqnft~lcLc-I>aEJSsL4-^5=Kt@Exk#R|RJ%wg=#WT8P z{)(_+0f9VKG1OalBQmYD=Eg-qy25dl<(0~Qy*VefA7b8)bu-Z>1NF+r5v$0hK@*pX zTDlLSsDkA0h$gyb%KU~kLN}tBlvJxU@lemGEOr~qVy!|1N?dEBE4(4lPU$(eQ8T5! z{HpDM^~kk@SK{za8&}hJ*NjWVGAo!e%*sr6dWY6rr#MV&`^zv<(d(9b`{Rz?xEU=V zW|eMryH|$gOJ&#bq%^)ySMdZk*evE(Jv;}{f}O+QM>+ zzH#kTgK0|;#?2Bw>`nSO2zk~%4>*>XG`yX8@Xh14+2jw4gf9nH(V7(AUtf%9drt}3 z5~>%ngXA8!dpx7|TF8ZDA+z2?OzKBcK8PH`U3hPKPD1<499Ow!%(j;vzV0>qPSqjj zv%E>+a(jzd5OI5(f!>9EC!dm6_WcM*&97iAqD~eD^^-5%CWQ6fJtP7*JvbtP>Pj9{ zAhR70CV~u`p60#2(iF7Ss(ngmC;qXsR>(o5#~tvaGV4Nl?ZZ{3J9^YRlftI1b{i_*Hg=wv5S1Py+5|g39y-!69ba?D7mV#w7t6!1?c8Eu@=CDM%86wD?~MQXPWt+u>*X5HT;BKh+;czo{d>mNgh0?o0Dz+r&f39R%ZUpB z02ui5jBy!Qnpm)be*pj!4FC)*EeM(5Yw&&Wy2-c*00061&-?~BuX-~~0?Mz(1XpAF zOJdE}nOaBIC8*2KWZ}%Nu08kWmZ7$BMJI0IX*aRyn0y+X*yB9J_U40kT-Jl`5hts1 znWsL}s7Wa*M+cniTonIgTaa+HBkFFG?fHJNS0EhtqkdBE?mrAD#X_NYfPLz5zC**D6L87(%yXRNrYH@PK zEUIyjJ83=Vk;633?kFMu=CNAM9)IU;XLP#xE~Lf^QFBsxnJrg#4sHN(M=5whvnWpb zlQ=(4Ny9?OW0B9^C7F?tb_ki9)+QZsvZnd!&zDh(9VbgAUMv^(dA-nUomV!?`88HI zd@60Ozmtm*&x$=_UH}LLfb@Tf>2X#KY(srRY)`El)!l}&x8p>0bH7%yC~n!Nw5fe9 zn#ytY6!>~S?2pbHMUh{4ddnSG=ggvJarWN3dJ~?DxyjuP=xh$pgiCmX)fux|mTBjJ zmxB7Qo>tD_C;%_M9+}N~96J$v6;C)ES6zTi<)Gz^7e7yI3@U-XYCRDkWpbCeDgUVh z8u?^CKCY3Oo&~iaod5uU$#M|@?*B`jOQ*irCbyqDM>mL_okgztcT8w3PeB;GC6cG) z!^joYdwPjWG?v=isw^Kk3;@kQDXvUePn>Ps><$=nLAqHuUX zNgm&>9h1V%TcOsX{GW&;0e~Fe(!#R!ni7$o-JFlvbXY!Pv&eO$0SgQxoq9#0@G#x88&(85o z*vVov+*Us+fB-Rt{u6O9xZv2^nP6r!)2Va78P#WP*l(y^sCDU+?C<xPtwmL8=pG}F|7Q34o#rEiZ22_IQuBb8`?J++GKzq_0AK_FEsTgWY6iGL0ac?g)o<9Hor6br z=9kpMYa`_J6^DS~f^61~X zTqZW~(P#~q{>V?2F((Up8jc;C{IjwzlKRW3(EiXm8NZ9?&1Q=*=v*D`F~sQKuu&T; z$Zb+jN)fMzc)lBh^9;zN%;4k$fYyJr+LX@8*Y*u3agvahzkKiYQ$0I{(L5{fXHb1S z&5%Xs*mN@6t!6_h4uy>0E%O=z<4Gj?9T`vJ|0g1h z95koT^M4x=q8;@Dimz81?SV4i8hk1Q!^n7S-+)7hX=-8{6k4OprXUVwVq=3n+jX$- zl6T0&vc|!>mm(@zU$OjuFrM`bcuz&fv%uebt72qM6#ZF>->%A6WTGxd*W|ieyr~^$ zrG~B)dM%b?D-!?ym-ZYDdcCgSB>Ik8i*`X%*lZ0rWvcXKVwewAeXxgL?nR8xs!es9 zhFnW~l6YC?HS8h8dqF5o9J~8-7qOmI>y;1RIPgVW7?E=8tGo;*FM5n*t8nK^?X*;&);C7%)8h} zD5F7Y@o8c4h4qA@v{9tzBzg4os@7?PHznEr16n2{5(X`}r|3&jB?~H(N&YJSk8vfM zaQg0~(@OyW3jjC+K>fcNeveM?+@}0(hF_$nH!qg^WFnOwh4tT@T$P9t!1TL)I<{y{ z_!!FkF)8L8&a~E;a7G{gLb5S-S6PIdk1eH%6cv~@-&r+4Hs{xAR=&&qb2?Yf(bwX+ zWs-;IqA*6h1`?bO|5YL7(9%1UCh2JK`e4Cfm}1Juuwx^zWlfO>2%l6GxBxlh>Tp>M zRQTuv81jVS6AMP{n?Xu3Mm4{%ubLOo=`ZZ7=HUL!*%>G_Uc;!RnH>rczh!d;Bg}N0 zl2)8tKznMhAOlvI{K3FJ+_jg=dB2)JDDyhX$$jjMDH#vzHZQmnm^tKh?){anE=|K( z-!)|$EzuinXy1*Wo-=(OaDuuW`&$B$-7EE2F%8=zv9+ko z&pd``ESiANN>Q_c*GW;-Bego9X%PA+P!6&R0|3}Z1NQY%GJOpJ`}(MeNln?RaW+3Z zL88|xu{8K@N-y^U?z(JHYW=wxBk(hjw2U0#Le!AdnV65uIu_pI4L9;sO1<~iMLdns)*_4tIFtN#(_BjWA0hMYq0csD{>{aJlk zhpaOaXk2#1_N)?anda`n2O1*e|6v9@5hyJGu@y!Y05#xyTXCi)<#q?NiBW7_vgKq7 z3YS^3rc(mN%OWSF;=vvot6nOjmOUUUZAE-}Jx%p5^LYs=%R)J`AfJ~#@m%c=)^VoFB--NZwY72Q%*GWgbL>6ti>};60A{6{Ln3kXFCUz6Dxc$w1ZGR zEO*Pc zs(J|TRKAqZL~cVi%igMF5y?if5FcW4Tl+g~g^}Ck=}48*2$G>Tn}CLH~=CqMLkE(y;oOHh)YdY)ks9^%Jk`(UcnR4ic##*v89MGzuWDC z(!@<68+q?&d32kH6B2Md^@c55s%*1(TrK!EipKNT1U! zuqh649PZ8=l0=uGdu27G<@H){5P|Vb)(_o+3cmc1>+DZ7=gSVfA+``Iy{k5rcAyb` zf&+_`%ZDbP@_Jutyde12e?mK5)d(WM6y1$AHaf`klJ;6(7cO1sfrrd_P;g3>eR#qQIkfBS{Uh@@lm(uCmulX*kSH-kn zZ=p;7qlUXNpIrL19aHomF?K48`S+RT+>f*uY1cL5UjIB4bQYPDIpnD>qVU^6_Z&s< zVops;wnfujo0@8un6Yctckfr=)Y=aXEX*&$EUV_+9^J7Um{FlU`Ku~(rLp?fBbL5K zGB`6Y#?HtZdQbV03ntLl@SO{Soib#e$5K*JE4&=O_Go2$6LS(lg1X^FWLj~ELm5JQsf2(Ikostnfu<%1@4*8M&R5AzX` zUs<85@*=rU<#9Uy4SDKMv)%Fv0g%5AP z#MR#1vK+eWd{)IbGIv*PeGF<4B0%CBk&wC5CPbo;tqsC>0^MPFsO_uAnRG(%knsOw zf2J4G3HNsyd+K15{AU7&$4Zp~<`72^h(T(IEZ%k`;s=J6Tn`Z-IN=s;b; zE|;to;`Y{xZ>m}2yAK8!qI%3d*sAR<9BabBA4Af4vm-eM&l1(2S6~;m$@LWrLSFd_ zjt!Hpd7qvZ>ONG|XgK6PE(h!#8f1C4xgCfhO`j!Re0UW$;P1Eny*WjY|LdCHuT zKaWA$A6a1@eHH&@h1nquf2oF*>zibde$^90D|$d2K5J3}T~|YW<>^|rnbg#s8s>1K zV68ikt0(bQt49Rvh4BK-=tPg{JjIsen5H0bxXneq#IMlloL@py9r=BMv%*1R2L&cQ5Y|cM39h1y z75pzVWK^6VnjwM6dsIEmtY)&2#Nx0n8_R3{76?JzpkXt5>G`sz{Zer9-o^I7?C7!! zmK`0Z+>wOTNNk@rs?#<|$nEx0QdFhUI)3?QS9f5gX$H@$1z9`ho3~~M$1Yluuc3~) zAH|E=EOiRR=dnGPT3^0kz*in2SgclxA%!}23D zq?^olW=QW-Ap?b&k|E3#&v`hZ{lSh*11c1?EEA&wmU_TXn#Yv_EmVU`PM{b!caE!9Ngdl_ufvsW*@HY&xzkRb=_#|#npWOXcqaBr_;l&1>VsF} zkqJtPO8H^AYccIEOy*1)iM?Z?M%KlNT*q55tWmovYOHquxFHA#j{Q4;(VzD30EWr@ zgfpe^dej!4n;57qv}Vpza&)ztLeme-U;4~zg4p}*JqL?eCU&?ik5sqD9U3AM$Rro` z2-w?59?0+6PHplE=XqVjz1i$Jn3JC6^c~cbt3&-xftru@T`yCRtKsun$uS+re%4lR c>Y70&?Mgkrr!qP9`{1MDv4G%5?+2m(2L!P9bpQYW literal 0 HcmV?d00001 diff --git a/test/images/static-logo.gif b/test/images/static-logo.gif new file mode 100644 index 0000000000000000000000000000000000000000..8108b87f6f24dc71a4a778878d790ecde7c8a4e3 GIT binary patch literal 999 zcmVx#0uvc1nVaIL(68{gUg86 zq${bZ zEc5zxxt4A}!B~yAzDbDf!cAnA%q!1m?DV< zHaOw}jHM=Di!y%bVTZrvmrac&!Z;g_JH}X&k1sCxBa%lV*im3M=J+I1LSDBbjXIVU zC6-AtITDdlUJ2EdVS>3@l^|w0CYo=Nxt^IpviYEk2dbDLXK;$SrkZWm>ExF5=@}=8 zZVm}3o_FcV;h^RDDd&lQGAbyauN8VHn~6$V8l{>-`lg~L@~7mSje^Q$U0!Or$uwvyzj^x554o)TMxeEhNQ2# zgN^I&Ki0-K@HPF~32>9(9-Q041^4*yHVmH^ZNPPeYhS`$YHTXUgCXjyp%RK5vZW$| zJPyfztju!DF24+O%rehRbIms2jC0O9@62=0KK~4K&_WMQbkRm1jdapVFU@q*PCpHG V)KX7Pb=6j1jdj*qZ!Jav06R-r@Sgwx literal 0 HcmV?d00001 diff --git a/test/images/static-logo.webp b/test/images/static-logo.webp new file mode 100644 index 0000000000000000000000000000000000000000..41014183421cf762a01db88a8a8cdb648f5165c3 GIT binary patch literal 146 zcmV;D0B!$LNk&GB00012MM6+kP&iC}0000l$G|ZV4=@1}Fah%)@Niq(R;)b77g7SH zP#Sb%D|8|y5IP@cuj?C064Cz&u<^Mud#m7+vQLMkirFOyPO;;KEIAPy%oG<=hlK+2 z4qUK-e9~-z3j_hr<8SaCsgM-2PBe`*f?X8Z8vc!wrmB)v7XNeak4DViDoko%1{tk7 Aj{pDw literal 0 HcmV?d00001 diff --git a/test/setupTests.js b/test/setupTests.js index 6ce8ad9491..0ff021eaa2 100644 --- a/test/setupTests.js +++ b/test/setupTests.js @@ -1,6 +1,7 @@ import { TextEncoder, TextDecoder } from 'util'; import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import { configure } from "enzyme"; +import "blob-polyfill"; // https://github.com/jsdom/jsdom/issues/2555 import * as languageHandler from "../src/languageHandler"; import SdkConfig, { DEFAULTS } from '../src/SdkConfig'; diff --git a/yarn.lock b/yarn.lock index 948b04b7f5..7696ed8f43 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2627,6 +2627,11 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== +blob-polyfill@^6.0.20211015: + version "6.0.20211015" + resolved "https://registry.yarnpkg.com/blob-polyfill/-/blob-polyfill-6.0.20211015.tgz#7c47e62347e302e8d1d1ee5e140b881f74bdb23e" + integrity sha512-OGL4bm6ZNpdFAvQugRlQy5MNly8gk15aWi/ZhQHimQsrx9WKD05r+v+xNgHCChLER3MH+9KLAhzuFlwFKrH1Yw== + bluebird@^3.5.0: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"