Skip to content

Commit a3e5231

Browse files
authored
Add support for Animated (A)PNG (matrix-org#8158)
1 parent 7798ecf commit a3e5231

File tree

5 files changed

+106
-48
lines changed

5 files changed

+106
-48
lines changed

src/utils/Image.ts

+81-38
Original file line numberDiff line numberDiff line change
@@ -14,62 +14,105 @@
1414
* limitations under the License.
1515
*/
1616

17+
import { arrayHasDiff } from "./arrays";
18+
1719
export function mayBeAnimated(mimeType: string): boolean {
18-
return ["image/gif", "image/webp"].includes(mimeType);
20+
return ["image/gif", "image/webp", "image/png", "image/apng"].includes(mimeType);
1921
}
2022

2123
function arrayBufferRead(arr: ArrayBuffer, start: number, len: number): Uint8Array {
2224
return new Uint8Array(arr.slice(start, start + len));
2325
}
2426

27+
function arrayBufferReadInt(arr: ArrayBuffer, start: number): number {
28+
const dv = new DataView(arr, start, 4);
29+
return dv.getUint32(0);
30+
}
31+
2532
function arrayBufferReadStr(arr: ArrayBuffer, start: number, len: number): string {
2633
return String.fromCharCode.apply(null, arrayBufferRead(arr, start, len));
2734
}
2835

2936
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);
37+
switch (mimeType) {
38+
case "image/webp": {
39+
// Only extended file format WEBP images support animation, so grab the expected data range and verify header.
40+
// Based on https://developers.google.com/speed/webp/docs/riff_container#extended_file_format
41+
const arr = await blob.slice(0, 17).arrayBuffer();
42+
if (
43+
arrayBufferReadStr(arr, 0, 4) === "RIFF" &&
44+
arrayBufferReadStr(arr, 8, 4) === "WEBP" &&
45+
arrayBufferReadStr(arr, 12, 4) === "VP8X"
46+
) {
47+
const [flags] = arrayBufferRead(arr, 16, 1);
48+
// Flags: R R I L E X _A_ R (reversed)
49+
const animationFlagMask = 1 << 1;
50+
return (flags & animationFlagMask) != 0;
51+
}
52+
break;
5653
}
5754

58-
// move on to the Graphics Control Extension
59-
const offset = 3 + globalColorTableSize;
55+
case "image/gif": {
56+
// Based on https://gist.github.com/zakirt/faa4a58cec5a7505b10e3686a226f285
57+
// More info at http://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp
58+
const dv = new DataView(await blob.arrayBuffer(), 10);
59+
60+
const globalColorTable = dv.getUint8(0);
61+
let globalColorTableSize = 0;
62+
// check first bit, if 0, then we don't have a Global Color Table
63+
if (globalColorTable & 0x80) {
64+
// grab the last 3 bits, to calculate the global color table size -> RGB * 2^(N+1)
65+
// N is the value in the last 3 bits.
66+
globalColorTableSize = 3 * Math.pow(2, (globalColorTable & 0x7) + 1);
67+
}
6068

61-
const extensionIntroducer = dv.getUint8(offset);
62-
const graphicsControlLabel = dv.getUint8(offset + 1);
63-
let delayTime = 0;
69+
// move on to the Graphics Control Extension
70+
const offset = 3 + globalColorTableSize;
6471

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);
72+
const extensionIntroducer = dv.getUint8(offset);
73+
const graphicsControlLabel = dv.getUint8(offset + 1);
74+
let delayTime = 0;
75+
76+
// Graphics Control Extension section is where GIF animation data is stored
77+
// First 2 bytes must be 0x21 and 0xF9
78+
if ((extensionIntroducer & 0x21) && (graphicsControlLabel & 0xF9)) {
79+
// skip to the 2 bytes with the delay time
80+
delayTime = dv.getUint16(offset + 4);
81+
}
82+
83+
return !!delayTime;
7084
}
7185

72-
return !!delayTime;
86+
case "image/png":
87+
case "image/apng": {
88+
// Based on https://stackoverflow.com/a/68618296
89+
const arr = await blob.arrayBuffer();
90+
if (arrayHasDiff([
91+
0x89,
92+
0x50, 0x4E, 0x47,
93+
0x0D, 0x0A,
94+
0x1A,
95+
0x0A,
96+
], Array.from(arrayBufferRead(arr, 0, 8)))) {
97+
return false;
98+
}
99+
100+
for (let i = 8; i < blob.size;) {
101+
const length = arrayBufferReadInt(arr, i);
102+
i += 4;
103+
const type = arrayBufferReadStr(arr, i, 4);
104+
i += 4;
105+
106+
switch (type) {
107+
case "acTL":
108+
return true;
109+
case "IDAT":
110+
return false;
111+
}
112+
i += length + 4;
113+
}
114+
break;
115+
}
73116
}
74117

75118
return false;

src/utils/blobs.ts

+1
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/apng',
5556
'image/webp',
5657

5758
'video/mp4',

test/Image-test.ts

+24-10
Original file line numberDiff line numberDiff line change
@@ -29,33 +29,47 @@ describe("Image", () => {
2929
expect(mayBeAnimated("image/webp")).toBeTruthy();
3030
});
3131
it("image/png", async () => {
32-
expect(mayBeAnimated("image/png")).toBeFalsy();
32+
expect(mayBeAnimated("image/png")).toBeTruthy();
33+
});
34+
it("image/apng", async () => {
35+
expect(mayBeAnimated("image/apng")).toBeTruthy();
3336
});
3437
it("image/jpeg", async () => {
3538
expect(mayBeAnimated("image/jpeg")).toBeFalsy();
3639
});
3740
});
3841

3942
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-
4543
it("Animated GIF", async () => {
46-
expect(await blobIsAnimated("image/gif", animatedGif)).toBeTruthy();
44+
const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "animated-logo.gif"))]);
45+
expect(await blobIsAnimated("image/gif", img)).toBeTruthy();
4746
});
4847

4948
it("Static GIF", async () => {
50-
expect(await blobIsAnimated("image/gif", staticGif)).toBeFalsy();
49+
const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "static-logo.gif"))]);
50+
expect(await blobIsAnimated("image/gif", img)).toBeFalsy();
5151
});
5252

5353
it("Animated WEBP", async () => {
54-
expect(await blobIsAnimated("image/webp", animatedWebp)).toBeTruthy();
54+
const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "animated-logo.webp"))]);
55+
expect(await blobIsAnimated("image/webp", img)).toBeTruthy();
5556
});
5657

5758
it("Static WEBP", async () => {
58-
expect(await blobIsAnimated("image/webp", staticWebp)).toBeFalsy();
59+
const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "static-logo.webp"))]);
60+
expect(await blobIsAnimated("image/webp", img)).toBeFalsy();
61+
});
62+
63+
it("Animated PNG", async () => {
64+
const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "animated-logo.apng"))]);
65+
expect(await blobIsAnimated("image/png", img)).toBeTruthy();
66+
expect(await blobIsAnimated("image/apng", img)).toBeTruthy();
67+
});
68+
69+
it("Static PNG", async () => {
70+
const img = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "static-logo.png"))]);
71+
expect(await blobIsAnimated("image/png", img)).toBeFalsy();
72+
expect(await blobIsAnimated("image/apng", img)).toBeFalsy();
5973
});
6074
});
6175
});

test/images/animated-logo.apng

46.4 KB
Binary file not shown.

test/images/static-logo.png

1.57 KB
Loading

0 commit comments

Comments
 (0)