Support APNGs in Sticker Creator
This commit is contained in:
parent
6b3d5c19b3
commit
bdd71e4898
20 changed files with 542 additions and 62 deletions
|
@ -14,6 +14,8 @@ import {
|
|||
} from '../state/ducks/conversations';
|
||||
import { ColorType } from '../types/Colors';
|
||||
import { MessageModel } from './messages';
|
||||
import { sniffImageMimeType } from '../util/sniffImageMimeType';
|
||||
import { MIMEType, IMAGE_WEBP } from '../types/MIME';
|
||||
|
||||
/* eslint-disable more/no-then */
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
@ -1773,6 +1775,23 @@ export class ConversationModel extends window.Backbone.Model<
|
|||
const { path, width, height } = stickerData;
|
||||
const arrayBuffer = await readStickerData(path);
|
||||
|
||||
// We need this content type to be an image so we can display an `<img>` instead of a
|
||||
// `<video>` or an error, but it's not critical that we get the full type correct.
|
||||
// In other words, it's probably fine if we say that a GIF is `image/png`, but it's
|
||||
// but it's bad if we say it's `video/mp4` or `text/plain`. We do our best to sniff
|
||||
// the MIME type here, but it's okay if we have to use a possibly-incorrect
|
||||
// fallback.
|
||||
let contentType: MIMEType;
|
||||
const sniffedMimeType = sniffImageMimeType(arrayBuffer);
|
||||
if (sniffedMimeType) {
|
||||
contentType = sniffedMimeType;
|
||||
} else {
|
||||
window.log.warn(
|
||||
'Unable to sniff sticker MIME type; falling back to WebP'
|
||||
);
|
||||
contentType = IMAGE_WEBP;
|
||||
}
|
||||
|
||||
const sticker = {
|
||||
packId,
|
||||
stickerId,
|
||||
|
@ -1780,7 +1799,7 @@ export class ConversationModel extends window.Backbone.Model<
|
|||
data: {
|
||||
size: arrayBuffer.byteLength,
|
||||
data: arrayBuffer,
|
||||
contentType: 'image/webp',
|
||||
contentType,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
|
|
57
ts/test/util/getAnimatedPngDataIfExists_test.ts
Normal file
57
ts/test/util/getAnimatedPngDataIfExists_test.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { assert } from 'chai';
|
||||
|
||||
import { getAnimatedPngDataIfExists } from '../../util/getAnimatedPngDataIfExists';
|
||||
|
||||
describe('getAnimatedPngDataIfExists', () => {
|
||||
const fixture = (filename: string): Promise<Buffer> => {
|
||||
const fixturePath = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'fixtures',
|
||||
filename
|
||||
);
|
||||
return fs.promises.readFile(fixturePath);
|
||||
};
|
||||
|
||||
it('returns null for empty buffers', () => {
|
||||
assert.isNull(getAnimatedPngDataIfExists(Buffer.alloc(0)));
|
||||
});
|
||||
|
||||
it('returns null for non-PNG files', async () => {
|
||||
await Promise.all(
|
||||
[
|
||||
'kitten-1-64-64.jpg',
|
||||
'512x515-thumbs-up-lincoln.webp',
|
||||
'giphy-GVNvOUpeYmI7e.gif',
|
||||
'pixabay-Soap-Bubble-7141.mp4',
|
||||
'lorem-ipsum.txt',
|
||||
].map(async filename => {
|
||||
assert.isNull(getAnimatedPngDataIfExists(await fixture(filename)));
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null for non-animated PNG files', async () => {
|
||||
assert.isNull(
|
||||
getAnimatedPngDataIfExists(await fixture('20x200-yellow.png'))
|
||||
);
|
||||
});
|
||||
|
||||
it('returns data for animated PNG files', async () => {
|
||||
assert.deepEqual(
|
||||
getAnimatedPngDataIfExists(
|
||||
await fixture('Animated_PNG_example_bouncing_beach_ball.png')
|
||||
),
|
||||
{ numPlays: Infinity }
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
getAnimatedPngDataIfExists(await fixture('apng_with_2_plays.png')),
|
||||
{ numPlays: 2 }
|
||||
);
|
||||
});
|
||||
});
|
92
ts/test/util/sniffImageMimeType_test.ts
Normal file
92
ts/test/util/sniffImageMimeType_test.ts
Normal file
|
@ -0,0 +1,92 @@
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { assert } from 'chai';
|
||||
import {
|
||||
IMAGE_BMP,
|
||||
IMAGE_GIF,
|
||||
IMAGE_ICO,
|
||||
IMAGE_JPEG,
|
||||
IMAGE_PNG,
|
||||
IMAGE_WEBP,
|
||||
} from '../../types/MIME';
|
||||
|
||||
import { sniffImageMimeType } from '../../util/sniffImageMimeType';
|
||||
|
||||
describe('sniffImageMimeType', () => {
|
||||
const fixture = (filename: string): Promise<Buffer> => {
|
||||
const fixturePath = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'fixtures',
|
||||
filename
|
||||
);
|
||||
return fs.promises.readFile(fixturePath);
|
||||
};
|
||||
|
||||
it('returns undefined for empty buffers', () => {
|
||||
assert.isUndefined(sniffImageMimeType(new Uint8Array()));
|
||||
});
|
||||
|
||||
it('returns undefined for non-image files', async () => {
|
||||
await Promise.all(
|
||||
['pixabay-Soap-Bubble-7141.mp4', 'lorem-ipsum.txt'].map(
|
||||
async filename => {
|
||||
assert.isUndefined(sniffImageMimeType(await fixture(filename)));
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('sniffs ICO files', async () => {
|
||||
assert.strictEqual(
|
||||
sniffImageMimeType(await fixture('kitten-1-64-64.ico')),
|
||||
IMAGE_ICO
|
||||
);
|
||||
});
|
||||
|
||||
it('sniffs BMP files', async () => {
|
||||
assert.strictEqual(sniffImageMimeType(await fixture('2x2.bmp')), IMAGE_BMP);
|
||||
});
|
||||
|
||||
it('sniffs GIF files', async () => {
|
||||
assert.strictEqual(
|
||||
sniffImageMimeType(await fixture('giphy-GVNvOUpeYmI7e.gif')),
|
||||
IMAGE_GIF
|
||||
);
|
||||
});
|
||||
|
||||
it('sniffs WEBP files', async () => {
|
||||
assert.strictEqual(
|
||||
sniffImageMimeType(await fixture('512x515-thumbs-up-lincoln.webp')),
|
||||
IMAGE_WEBP
|
||||
);
|
||||
});
|
||||
|
||||
it('sniffs PNG files', async () => {
|
||||
await Promise.all(
|
||||
[
|
||||
'freepngs-2cd43b_bed7d1327e88454487397574d87b64dc_mv2.png',
|
||||
'Animated_PNG_example_bouncing_beach_ball.png',
|
||||
].map(async filename => {
|
||||
assert.strictEqual(
|
||||
sniffImageMimeType(await fixture(filename)),
|
||||
IMAGE_PNG
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('sniffs JPEG files', async () => {
|
||||
assert.strictEqual(
|
||||
sniffImageMimeType(await fixture('kitten-1-64-64.jpg')),
|
||||
IMAGE_JPEG
|
||||
);
|
||||
});
|
||||
|
||||
it('handles ArrayBuffers', async () => {
|
||||
const arrayBuffer = (await fixture('kitten-1-64-64.jpg')).buffer;
|
||||
assert.strictEqual(sniffImageMimeType(arrayBuffer), IMAGE_JPEG);
|
||||
});
|
||||
});
|
|
@ -8,6 +8,8 @@ export const IMAGE_GIF = 'image/gif' as MIMEType;
|
|||
export const IMAGE_JPEG = 'image/jpeg' as MIMEType;
|
||||
export const IMAGE_PNG = 'image/png' as MIMEType;
|
||||
export const IMAGE_WEBP = 'image/webp' as MIMEType;
|
||||
export const IMAGE_ICO = 'image/x-icon' as MIMEType;
|
||||
export const IMAGE_BMP = 'image/bmp' as MIMEType;
|
||||
export const VIDEO_MP4 = 'video/mp4' as MIMEType;
|
||||
export const VIDEO_QUICKTIME = 'video/quicktime' as MIMEType;
|
||||
export const LONG_MESSAGE = 'text/x-signal-plain' as MIMEType;
|
||||
|
|
78
ts/util/getAnimatedPngDataIfExists.ts
Normal file
78
ts/util/getAnimatedPngDataIfExists.ts
Normal file
|
@ -0,0 +1,78 @@
|
|||
const PNG_SIGNATURE = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const ACTL_CHUNK_BYTES = new TextEncoder().encode('acTL');
|
||||
const IDAT_CHUNK_BYTES = new TextEncoder().encode('IDAT');
|
||||
const MAX_BYTES_TO_READ = 1024 * 1024;
|
||||
|
||||
interface AnimatedPngData {
|
||||
numPlays: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a naïve implementation. It only performs two checks:
|
||||
*
|
||||
* 1. Do the bytes start with the [PNG signature][0]?
|
||||
* 2. If so, does it contain the [`acTL` chunk][1] before the [`IDAT` chunk][2], in the
|
||||
* first megabyte?
|
||||
*
|
||||
* Though we _could_ only check for the precense of the `acTL` chunk anywhere, we make
|
||||
* sure it's before the `IDAT` chunk and within the first megabyte. This adds a small
|
||||
* amount of validity checking and helps us avoid problems with large PNGs.
|
||||
*
|
||||
* It doesn't make sure the PNG is valid. It doesn't verify [the CRC code][3] of each PNG
|
||||
* chunk; it doesn't verify any of the chunk's data; it doesn't verify that the chunks are
|
||||
* in the right order; etc.
|
||||
*
|
||||
* [0]: https://www.w3.org/TR/PNG/#5PNG-file-signature
|
||||
* [1]: https://wiki.mozilla.org/APNG_Specification#.60acTL.60:_The_Animation_Control_Chunk
|
||||
* [2]: https://www.w3.org/TR/PNG/#11IDAT
|
||||
* [3]: https://www.w3.org/TR/PNG/#5Chunk-layout
|
||||
*/
|
||||
export function getAnimatedPngDataIfExists(
|
||||
bytes: Uint8Array
|
||||
): null | AnimatedPngData {
|
||||
if (!hasPngSignature(bytes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let numPlays: void | number;
|
||||
|
||||
const dataView = new DataView(bytes.buffer);
|
||||
|
||||
let i = PNG_SIGNATURE.length;
|
||||
while (i < bytes.byteLength && i <= MAX_BYTES_TO_READ) {
|
||||
const chunkTypeBytes = bytes.slice(i + 4, i + 8);
|
||||
if (areBytesEqual(chunkTypeBytes, ACTL_CHUNK_BYTES)) {
|
||||
// 4 bytes for the length; 4 bytes for the type; 4 bytes for the number of frames.
|
||||
numPlays = dataView.getUint32(i + 12);
|
||||
if (numPlays === 0) {
|
||||
numPlays = Infinity;
|
||||
}
|
||||
return { numPlays };
|
||||
}
|
||||
if (areBytesEqual(chunkTypeBytes, IDAT_CHUNK_BYTES)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Jump over the length (4 bytes), the type (4 bytes), the data, and the CRC checksum
|
||||
// (4 bytes).
|
||||
i += 12 + dataView.getUint32(i);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasPngSignature(bytes: Uint8Array): boolean {
|
||||
return areBytesEqual(bytes.slice(0, 8), PNG_SIGNATURE);
|
||||
}
|
||||
|
||||
function areBytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.byteLength !== b.byteLength) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < a.byteLength; i += 1) {
|
||||
if (a[i] !== b[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
127
ts/util/sniffImageMimeType.ts
Normal file
127
ts/util/sniffImageMimeType.ts
Normal file
|
@ -0,0 +1,127 @@
|
|||
import {
|
||||
IMAGE_BMP,
|
||||
IMAGE_GIF,
|
||||
IMAGE_ICO,
|
||||
IMAGE_JPEG,
|
||||
IMAGE_PNG,
|
||||
IMAGE_WEBP,
|
||||
MIMEType,
|
||||
} from '../types/MIME';
|
||||
|
||||
/**
|
||||
* This follows the [MIME Sniffing Standard for images][0].
|
||||
*
|
||||
* [0]: https://mimesniff.spec.whatwg.org/#matching-an-image-type-pattern
|
||||
*/
|
||||
export function sniffImageMimeType(
|
||||
bytes: ArrayBuffer | Uint8Array
|
||||
): undefined | MIMEType {
|
||||
const asTypedArray = new Uint8Array(bytes);
|
||||
for (let i = 0; i < TYPES.length; i += 1) {
|
||||
const type = TYPES[i];
|
||||
if (matchesType(asTypedArray, type)) {
|
||||
return type.mimeType;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface Type {
|
||||
mimeType: MIMEType;
|
||||
bytePattern: Uint8Array;
|
||||
patternMask?: Uint8Array;
|
||||
}
|
||||
const TYPES: Array<Type> = [
|
||||
{
|
||||
mimeType: IMAGE_ICO,
|
||||
bytePattern: new Uint8Array([0x00, 0x00, 0x01, 0x00]),
|
||||
},
|
||||
{
|
||||
mimeType: IMAGE_ICO,
|
||||
bytePattern: new Uint8Array([0x00, 0x00, 0x02, 0x00]),
|
||||
},
|
||||
{
|
||||
mimeType: IMAGE_BMP,
|
||||
bytePattern: new Uint8Array([0x42, 0x4d]),
|
||||
},
|
||||
{
|
||||
mimeType: IMAGE_GIF,
|
||||
bytePattern: new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]),
|
||||
},
|
||||
{
|
||||
mimeType: IMAGE_GIF,
|
||||
bytePattern: new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]),
|
||||
},
|
||||
{
|
||||
mimeType: IMAGE_WEBP,
|
||||
bytePattern: new Uint8Array([
|
||||
0x52,
|
||||
0x49,
|
||||
0x46,
|
||||
0x46,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x57,
|
||||
0x45,
|
||||
0x42,
|
||||
0x50,
|
||||
0x56,
|
||||
0x50,
|
||||
]),
|
||||
patternMask: new Uint8Array([
|
||||
0xff,
|
||||
0xff,
|
||||
0xff,
|
||||
0xff,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0xff,
|
||||
0xff,
|
||||
0xff,
|
||||
0xff,
|
||||
0xff,
|
||||
0xff,
|
||||
]),
|
||||
},
|
||||
{
|
||||
mimeType: IMAGE_PNG,
|
||||
bytePattern: new Uint8Array([
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
]),
|
||||
},
|
||||
{
|
||||
mimeType: IMAGE_JPEG,
|
||||
bytePattern: new Uint8Array([0xff, 0xd8, 0xff]),
|
||||
},
|
||||
];
|
||||
|
||||
// This follows the [pattern matching algorithm in the spec][1].
|
||||
// [1]: https://mimesniff.spec.whatwg.org/#pattern-matching-algorithm
|
||||
function matchesType(input: Uint8Array, type: Type): boolean {
|
||||
if (input.byteLength < type.bytePattern.byteLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let p = 0; p < type.bytePattern.length; p += 1) {
|
||||
const mask = type.patternMask ? type.patternMask[p] : 0xff;
|
||||
// We need to use a bitwise operator here, per the spec.
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const maskedData = input[p] & mask;
|
||||
if (maskedData !== type.bytePattern[p]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue