Skip to content

Commit bc01efa

Browse files
authored
Improve handling of animated GIF and WEBP images (matrix-org#8153)
1 parent 50fd245 commit bc01efa

13 files changed

+297
-126
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@
166166
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.1",
167167
"allchange": "^1.0.6",
168168
"babel-jest": "^26.6.3",
169+
"blob-polyfill": "^6.0.20211015",
169170
"chokidar": "^3.5.1",
170171
"concurrently": "^5.3.0",
171172
"enzyme": "^3.11.0",

src/ContentMessages.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,17 @@ interface IThumbnail {
106106
* @param {HTMLElement} element The element to thumbnail.
107107
* @param {number} inputWidth The width of the image in the input element.
108108
* @param {number} inputHeight the width of the image in the input element.
109-
* @param {String} mimeType The mimeType to save the blob as.
109+
* @param {string} mimeType The mimeType to save the blob as.
110+
* @param {boolean} calculateBlurhash Whether to calculate a blurhash of the given image too.
110111
* @return {Promise} A promise that resolves with an object with an info key
111112
* and a thumbnail key.
112113
*/
113-
async function createThumbnail(
114+
export async function createThumbnail(
114115
element: ThumbnailableElement,
115116
inputWidth: number,
116117
inputHeight: number,
117118
mimeType: string,
119+
calculateBlurhash = true,
118120
): Promise<IThumbnail> {
119121
let targetWidth = inputWidth;
120122
let targetHeight = inputHeight;
@@ -152,7 +154,7 @@ async function createThumbnail(
152154

153155
const imageData = context.getImageData(0, 0, targetWidth, targetHeight);
154156
// thumbnailPromise and blurhash promise are being awaited concurrently
155-
const blurhash = await BlurhashEncoder.instance.getBlurhash(imageData);
157+
const blurhash = calculateBlurhash ? await BlurhashEncoder.instance.getBlurhash(imageData) : undefined;
156158
const thumbnail = await thumbnailPromise;
157159

158160
return {

src/components/views/messages/MImageBody.tsx

Lines changed: 145 additions & 119 deletions
Large diffs are not rendered by default.

src/components/views/messages/MImageReplyBody.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,12 @@ export default class MImageReplyBody extends MImageBody {
4141
}
4242

4343
render() {
44-
if (this.state.error !== null) {
44+
if (this.state.error) {
4545
return super.render();
4646
}
4747

4848
const content = this.props.mxEvent.getContent<IMediaEventContent>();
49-
50-
const contentUrl = this.getContentUrl();
51-
const thumbnail = this.messageContent(contentUrl, this.getThumbUrl(), content, FORCED_IMAGE_HEIGHT);
49+
const thumbnail = this.messageContent(this.state.contentUrl, this.state.thumbUrl, content, FORCED_IMAGE_HEIGHT);
5250
const fileBody = this.getFileBody();
5351
const sender = <SenderProfile
5452
mxEvent={this.props.mxEvent}

src/utils/Image.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* Copyright 2022 The Matrix.org Foundation C.I.C.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
export function mayBeAnimated(mimeType: string): boolean {
18+
return ["image/gif", "image/webp"].includes(mimeType);
19+
}
20+
21+
function arrayBufferRead(arr: ArrayBuffer, start: number, len: number): Uint8Array {
22+
return new Uint8Array(arr.slice(start, start + len));
23+
}
24+
25+
function arrayBufferReadStr(arr: ArrayBuffer, start: number, len: number): string {
26+
return String.fromCharCode.apply(null, arrayBufferRead(arr, start, len));
27+
}
28+
29+
export async function blobIsAnimated(mimeType: string, blob: Blob): Promise<boolean> {
30+
if (mimeType === "image/webp") {
31+
// Only extended file format WEBP images support animation, so grab the expected data range and verify header.
32+
// Based on https://developers.google.com/speed/webp/docs/riff_container#extended_file_format
33+
const arr = await blob.slice(0, 17).arrayBuffer();
34+
if (
35+
arrayBufferReadStr(arr, 0, 4) === "RIFF" &&
36+
arrayBufferReadStr(arr, 8, 4) === "WEBP" &&
37+
arrayBufferReadStr(arr, 12, 4) === "VP8X"
38+
) {
39+
const [flags] = arrayBufferRead(arr, 16, 1);
40+
// Flags: R R I L E X _A_ R (reversed)
41+
const animationFlagMask = 1 << 1;
42+
return (flags & animationFlagMask) != 0;
43+
}
44+
} else if (mimeType === "image/gif") {
45+
// Based on https://gist.github.com/zakirt/faa4a58cec5a7505b10e3686a226f285
46+
// More info at http://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp
47+
const dv = new DataView(await blob.arrayBuffer(), 10);
48+
49+
const globalColorTable = dv.getUint8(0);
50+
let globalColorTableSize = 0;
51+
// check first bit, if 0, then we don't have a Global Color Table
52+
if (globalColorTable & 0x80) {
53+
// grab the last 3 bits, to calculate the global color table size -> RGB * 2^(N+1)
54+
// N is the value in the last 3 bits.
55+
globalColorTableSize = 3 * Math.pow(2, (globalColorTable & 0x7) + 1);
56+
}
57+
58+
// move on to the Graphics Control Extension
59+
const offset = 3 + globalColorTableSize;
60+
61+
const extensionIntroducer = dv.getUint8(offset);
62+
const graphicsControlLabel = dv.getUint8(offset + 1);
63+
let delayTime = 0;
64+
65+
// Graphics Control Extension section is where GIF animation data is stored
66+
// First 2 bytes must be 0x21 and 0xF9
67+
if ((extensionIntroducer & 0x21) && (graphicsControlLabel & 0xF9)) {
68+
// skip to the 2 bytes with the delay time
69+
delayTime = dv.getUint16(offset + 4);
70+
}
71+
72+
return !!delayTime;
73+
}
74+
75+
return false;
76+
}

src/utils/blobs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const ALLOWED_BLOB_MIMETYPES = [
5252
'image/jpeg',
5353
'image/gif',
5454
'image/png',
55+
'image/webp',
5556

5657
'video/mp4',
5758
'video/webm',

test/Image-test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import fs from "fs";
18+
import path from "path";
19+
20+
import './skinned-sdk';
21+
import { blobIsAnimated, mayBeAnimated } from "../src/utils/Image";
22+
23+
describe("Image", () => {
24+
describe("mayBeAnimated", () => {
25+
it("image/gif", async () => {
26+
expect(mayBeAnimated("image/gif")).toBeTruthy();
27+
});
28+
it("image/webp", async () => {
29+
expect(mayBeAnimated("image/webp")).toBeTruthy();
30+
});
31+
it("image/png", async () => {
32+
expect(mayBeAnimated("image/png")).toBeFalsy();
33+
});
34+
it("image/jpeg", async () => {
35+
expect(mayBeAnimated("image/jpeg")).toBeFalsy();
36+
});
37+
});
38+
39+
describe("blobIsAnimated", () => {
40+
const animatedGif = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "animated-logo.gif"))]);
41+
const animatedWebp = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "animated-logo.webp"))]);
42+
const staticGif = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "static-logo.gif"))]);
43+
const staticWebp = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "static-logo.webp"))]);
44+
45+
it("Animated GIF", async () => {
46+
expect(await blobIsAnimated("image/gif", animatedGif)).toBeTruthy();
47+
});
48+
49+
it("Static GIF", async () => {
50+
expect(await blobIsAnimated("image/gif", staticGif)).toBeFalsy();
51+
});
52+
53+
it("Animated WEBP", async () => {
54+
expect(await blobIsAnimated("image/webp", animatedWebp)).toBeTruthy();
55+
});
56+
57+
it("Static WEBP", async () => {
58+
expect(await blobIsAnimated("image/webp", staticWebp)).toBeFalsy();
59+
});
60+
});
61+
});

test/images/animated-logo.gif

54.3 KB
Loading

test/images/animated-logo.webp

5.07 KB
Binary file not shown.

test/images/static-logo.gif

999 Bytes
Loading

test/images/static-logo.webp

146 Bytes
Binary file not shown.

test/setupTests.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { TextEncoder, TextDecoder } from 'util';
22
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
33
import { configure } from "enzyme";
4+
import "blob-polyfill"; // https://github.com/jsdom/jsdom/issues/2555
45

56
import * as languageHandler from "../src/languageHandler";
67
import SdkConfig, { DEFAULTS } from '../src/SdkConfig';

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2627,6 +2627,11 @@ binary-extensions@^2.0.0:
26272627
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
26282628
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
26292629

2630+
blob-polyfill@^6.0.20211015:
2631+
version "6.0.20211015"
2632+
resolved "https://registry.yarnpkg.com/blob-polyfill/-/blob-polyfill-6.0.20211015.tgz#7c47e62347e302e8d1d1ee5e140b881f74bdb23e"
2633+
integrity sha512-OGL4bm6ZNpdFAvQugRlQy5MNly8gk15aWi/ZhQHimQsrx9WKD05r+v+xNgHCChLER3MH+9KLAhzuFlwFKrH1Yw==
2634+
26302635
bluebird@^3.5.0:
26312636
version "3.7.2"
26322637
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"

0 commit comments

Comments
 (0)