Support APNGs in Sticker Creator
This commit is contained in:
parent
6b3d5c19b3
commit
bdd71e4898
20 changed files with 542 additions and 62 deletions
|
@ -25,6 +25,7 @@ test/blanket_mocha.js
|
||||||
# TypeScript generated files
|
# TypeScript generated files
|
||||||
ts/**/*.js
|
ts/**/*.js
|
||||||
sticker-creator/**/*.js
|
sticker-creator/**/*.js
|
||||||
|
!sticker-creator/preload.js
|
||||||
|
|
||||||
**/*.d.ts
|
**/*.d.ts
|
||||||
webpack.config.ts
|
webpack.config.ts
|
||||||
|
|
|
@ -2331,7 +2331,7 @@
|
||||||
"description": "Title for the drop stage of the sticker creator"
|
"description": "Title for the drop stage of the sticker creator"
|
||||||
},
|
},
|
||||||
"StickerCreator--DropStage--help": {
|
"StickerCreator--DropStage--help": {
|
||||||
"message": "Stickers must be in PNG or WebP format with a transparent background and 512x512 pixels. Recommended margin is 16px.",
|
"message": "Stickers must be in PNG, APNG, or WebP format with a transparent background and 512x512 pixels. Recommended margin is 16px.",
|
||||||
"description": "Help text for the drop stage of the sticker creator"
|
"description": "Help text for the drop stage of the sticker creator"
|
||||||
},
|
},
|
||||||
"StickerCreator--DropStage--showMargins": {
|
"StickerCreator--DropStage--showMargins": {
|
||||||
|
@ -2460,7 +2460,23 @@
|
||||||
},
|
},
|
||||||
"StickerCreator--Toasts--errorProcessing": {
|
"StickerCreator--Toasts--errorProcessing": {
|
||||||
"message": "Error processing image",
|
"message": "Error processing image",
|
||||||
"description": "Text for the toast when an image cannot be processed was dropped on the sticker creator"
|
"description": "Text for the toast when an image cannot be processed was dropped on the sticker creator with a generic error"
|
||||||
|
},
|
||||||
|
"StickerCreator--Toasts--APNG--notSquare": {
|
||||||
|
"message": "Animated PNG stickers must be square",
|
||||||
|
"description": "Text for the toast when someone tries to upload a non-square APNG"
|
||||||
|
},
|
||||||
|
"StickerCreator--Toasts--mustLoopForever": {
|
||||||
|
"message": "Animated stickers must loop forever",
|
||||||
|
"description": "Text for the toast when an image in the sticker creator does not animate forever"
|
||||||
|
},
|
||||||
|
"StickerCreator--Toasts--APNG--dimensionsTooLarge": {
|
||||||
|
"message": "Animated PNG sticker dimensions are too large",
|
||||||
|
"description": "Text for the toast when an APNG image in the sticker creator is too large"
|
||||||
|
},
|
||||||
|
"StickerCreator--Toasts--APNG--dimensionsTooSmall": {
|
||||||
|
"message": "Animated PNG sticker dimensions are too small",
|
||||||
|
"description": "Text for the toast when an APNG image in the sticker creator is too small"
|
||||||
},
|
},
|
||||||
"StickerCreator--Toasts--errorUploading": {
|
"StickerCreator--Toasts--errorUploading": {
|
||||||
"message": "Error uploading stickers: $message$",
|
"message": "Error uploading stickers: $message$",
|
||||||
|
|
BIN
fixtures/2x2.bmp
Normal file
BIN
fixtures/2x2.bmp
Normal file
Binary file not shown.
After Width: | Height: | Size: 70 B |
BIN
fixtures/Animated_PNG_example_bouncing_beach_ball.png
Normal file
BIN
fixtures/Animated_PNG_example_bouncing_beach_ball.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 62 KiB |
BIN
fixtures/apng_with_2_plays.png
Normal file
BIN
fixtures/apng_with_2_plays.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 646 B |
BIN
fixtures/kitten-1-64-64.ico
Normal file
BIN
fixtures/kitten-1-64-64.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
|
@ -508,7 +508,6 @@ exports.processNewSticker = async (
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contentType: 'image/webp',
|
|
||||||
path,
|
path,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { FileWithPath, useDropzone } from 'react-dropzone';
|
import { FileWithPath } from 'react-dropzone';
|
||||||
import { AppStage } from './AppStage';
|
import { AppStage } from './AppStage';
|
||||||
import * as styles from './MetaStage.scss';
|
import * as styles from './MetaStage.scss';
|
||||||
import { convertToWebp } from '../../util/preload';
|
import { processStickerImage } from '../../util/preload';
|
||||||
|
import { useStickerDropzone } from '../../util/useStickerDropzone';
|
||||||
import { history } from '../../util/history';
|
import { history } from '../../util/history';
|
||||||
import { H2, Text } from '../../elements/Typography';
|
import { H2, Text } from '../../elements/Typography';
|
||||||
import { LabeledInput } from '../../elements/LabeledInput';
|
import { LabeledInput } from '../../elements/LabeledInput';
|
||||||
|
@ -22,8 +23,8 @@ export const MetaStage: React.ComponentType = () => {
|
||||||
const onDrop = React.useCallback(
|
const onDrop = React.useCallback(
|
||||||
async ([{ path }]: Array<FileWithPath>) => {
|
async ([{ path }]: Array<FileWithPath>) => {
|
||||||
try {
|
try {
|
||||||
const webp = await convertToWebp(path);
|
const stickerImage = await processStickerImage(path);
|
||||||
actions.setCover(webp);
|
actions.setCover(stickerImage);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
actions.removeSticker(path);
|
actions.removeSticker(path);
|
||||||
}
|
}
|
||||||
|
@ -31,10 +32,9 @@ export const MetaStage: React.ComponentType = () => {
|
||||||
[actions]
|
[actions]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
const { getRootProps, getInputProps, isDragActive } = useStickerDropzone(
|
||||||
onDrop,
|
onDrop
|
||||||
accept: ['image/png', 'image/webp'],
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const onNext = React.useCallback(() => {
|
const onNext = React.useCallback(() => {
|
||||||
setConfirming(true);
|
setConfirming(true);
|
||||||
|
|
|
@ -9,7 +9,7 @@ import * as styles from './StickerGrid.scss';
|
||||||
import { Props as StickerFrameProps, StickerFrame } from './StickerFrame';
|
import { Props as StickerFrameProps, StickerFrame } from './StickerFrame';
|
||||||
import { stickersDuck } from '../store';
|
import { stickersDuck } from '../store';
|
||||||
import { DropZone, Props as DropZoneProps } from '../elements/DropZone';
|
import { DropZone, Props as DropZoneProps } from '../elements/DropZone';
|
||||||
import { convertToWebp } from '../util/preload';
|
import { processStickerImage } from '../util/preload';
|
||||||
|
|
||||||
const queue = new PQueue({ concurrency: 3, timeout: 1000 * 60 * 2 });
|
const queue = new PQueue({ concurrency: 3, timeout: 1000 * 60 * 2 });
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@ const SmartStickerFrame = SortableElement(
|
||||||
({ id, showGuide, mode }: StickerFrameProps) => {
|
({ id, showGuide, mode }: StickerFrameProps) => {
|
||||||
const data = stickersDuck.useStickerData(id);
|
const data = stickersDuck.useStickerData(id);
|
||||||
const actions = stickersDuck.useStickerActions();
|
const actions = stickersDuck.useStickerActions();
|
||||||
const image = data.webp ? data.webp.src : undefined;
|
const image = data.imageData ? data.imageData.src : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StickerFrame
|
<StickerFrame
|
||||||
|
@ -52,13 +52,15 @@ const InnerGrid = SortableContainer(
|
||||||
paths.forEach(path => {
|
paths.forEach(path => {
|
||||||
queue.add(async () => {
|
queue.add(async () => {
|
||||||
try {
|
try {
|
||||||
const webp = await convertToWebp(path);
|
const stickerImage = await processStickerImage(path);
|
||||||
actions.addWebp(webp);
|
actions.addImageData(stickerImage);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
window.log.error('Error processing image:', e);
|
window.log.error('Error processing image:', e);
|
||||||
actions.removeSticker(path);
|
actions.removeSticker(path);
|
||||||
actions.addToast({
|
actions.addToast({
|
||||||
key: 'StickerCreator--Toasts--errorProcessing',
|
key:
|
||||||
|
(e || {}).errorMessageI18nKey ||
|
||||||
|
'StickerCreator--Toasts--errorProcessing',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useDropzone, FileWithPath } from 'react-dropzone';
|
import { FileWithPath } from 'react-dropzone';
|
||||||
import * as styles from './DropZone.scss';
|
import * as styles from './DropZone.scss';
|
||||||
import { useI18n } from '../util/i18n';
|
import { useI18n } from '../util/i18n';
|
||||||
|
import { useStickerDropzone } from '../util/useStickerDropzone';
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
readonly inner?: boolean;
|
readonly inner?: boolean;
|
||||||
|
@ -32,10 +33,9 @@ export const DropZone: React.ComponentType<Props> = props => {
|
||||||
[onDrop]
|
[onDrop]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
const { getRootProps, getInputProps, isDragActive } = useStickerDropzone(
|
||||||
onDrop: handleDrop,
|
handleDrop
|
||||||
accept: ['image/png', 'image/webp'],
|
);
|
||||||
});
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (onDragActive) {
|
if (onDragActive) {
|
||||||
|
|
|
@ -12,6 +12,11 @@ const { makeGetter } = require('../preload_utils');
|
||||||
const { dialog } = remote;
|
const { dialog } = remote;
|
||||||
const { nativeTheme } = remote.require('electron');
|
const { nativeTheme } = remote.require('electron');
|
||||||
|
|
||||||
|
const STICKER_SIZE = 512;
|
||||||
|
const MIN_STICKER_DIMENSION = 10;
|
||||||
|
const MAX_STICKER_DIMENSION = STICKER_SIZE;
|
||||||
|
const MAX_ANIMATED_STICKER_BYTE_LENGTH = 300 * 1024;
|
||||||
|
|
||||||
window.ROOT_PATH = window.location.href.startsWith('file') ? '../../' : '/';
|
window.ROOT_PATH = window.location.href.startsWith('file') ? '../../' : '/';
|
||||||
window.PROTO_ROOT = '../../protos';
|
window.PROTO_ROOT = '../../protos';
|
||||||
window.getEnvironment = () => config.environment;
|
window.getEnvironment = () => config.environment;
|
||||||
|
@ -32,6 +37,9 @@ window.Signal = Signal.setup({});
|
||||||
window.textsecure = require('../ts/textsecure').default;
|
window.textsecure = require('../ts/textsecure').default;
|
||||||
|
|
||||||
const { initialize: initializeWebAPI } = require('../ts/textsecure/WebAPI');
|
const { initialize: initializeWebAPI } = require('../ts/textsecure/WebAPI');
|
||||||
|
const {
|
||||||
|
getAnimatedPngDataIfExists,
|
||||||
|
} = require('../ts/util/getAnimatedPngDataIfExists');
|
||||||
|
|
||||||
const WebAPI = initializeWebAPI({
|
const WebAPI = initializeWebAPI({
|
||||||
url: config.serverUrl,
|
url: config.serverUrl,
|
||||||
|
@ -49,25 +57,83 @@ const WebAPI = initializeWebAPI({
|
||||||
version: config.version,
|
version: config.version,
|
||||||
});
|
});
|
||||||
|
|
||||||
window.convertToWebp = async (path, width = 512, height = 512) => {
|
function processStickerError(message, i18nKey) {
|
||||||
|
const result = new Error(message);
|
||||||
|
result.errorMessageI18nKey = i18nKey;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.processStickerImage = async path => {
|
||||||
const imgBuffer = await pify(readFile)(path);
|
const imgBuffer = await pify(readFile)(path);
|
||||||
const sharpImg = sharp(imgBuffer);
|
const sharpImg = sharp(imgBuffer);
|
||||||
const meta = await sharpImg.metadata();
|
const meta = await sharpImg.metadata();
|
||||||
|
|
||||||
const buffer = await sharpImg
|
const { width, height } = meta;
|
||||||
|
if (!width || !height) {
|
||||||
|
throw processStickerError(
|
||||||
|
'Sticker height or width were falsy',
|
||||||
|
'StickerCreator--Toasts--errorProcessing'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let contentType;
|
||||||
|
let processedBuffer;
|
||||||
|
|
||||||
|
// [Sharp doesn't support APNG][0], so we do something simpler: validate the file size
|
||||||
|
// and dimensions without resizing, cropping, or converting. In a perfect world, we'd
|
||||||
|
// resize and convert any animated image (GIF, animated WebP) to APNG.
|
||||||
|
// [0]: https://github.com/lovell/sharp/issues/2375
|
||||||
|
const animatedPngDataIfExists = getAnimatedPngDataIfExists(imgBuffer);
|
||||||
|
if (animatedPngDataIfExists) {
|
||||||
|
if (imgBuffer.byteLength > MAX_ANIMATED_STICKER_BYTE_LENGTH) {
|
||||||
|
throw processStickerError(
|
||||||
|
'Sticker file was too large',
|
||||||
|
'StickerCreator--Toasts--tooLarge'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (width !== height) {
|
||||||
|
throw processStickerError(
|
||||||
|
'Sticker must be square',
|
||||||
|
'StickerCreator--Toasts--APNG--notSquare'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (width > MAX_STICKER_DIMENSION) {
|
||||||
|
throw processStickerError(
|
||||||
|
'Sticker dimensions are too large',
|
||||||
|
'StickerCreator--Toasts--APNG--dimensionsTooLarge'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (width < MIN_STICKER_DIMENSION) {
|
||||||
|
throw processStickerError(
|
||||||
|
'Sticker dimensions are too small',
|
||||||
|
'StickerCreator--Toasts--APNG--dimensionsTooSmall'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (animatedPngDataIfExists.numPlays !== Infinity) {
|
||||||
|
throw processStickerError(
|
||||||
|
'Animated stickers must loop forever',
|
||||||
|
'StickerCreator--Toasts--mustLoopForever'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
contentType = 'image/png';
|
||||||
|
processedBuffer = imgBuffer;
|
||||||
|
} else {
|
||||||
|
contentType = 'image/webp';
|
||||||
|
processedBuffer = await sharpImg
|
||||||
.resize({
|
.resize({
|
||||||
width,
|
width: STICKER_SIZE,
|
||||||
height,
|
height: STICKER_SIZE,
|
||||||
fit: 'contain',
|
fit: 'contain',
|
||||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||||
})
|
})
|
||||||
.webp()
|
.webp()
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path,
|
path,
|
||||||
buffer,
|
buffer: processedBuffer,
|
||||||
src: `data:image/webp;base64,${buffer.toString('base64')}`,
|
src: `data:${contentType};base64,${processedBuffer.toString('base64')}`,
|
||||||
meta,
|
meta,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -108,7 +174,10 @@ window.encryptAndUpload = async (
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
|
|
||||||
const uniqueStickers = uniqBy([...stickers, { webp: cover }], 'webp');
|
const uniqueStickers = uniqBy(
|
||||||
|
[...stickers, { imageData: cover }],
|
||||||
|
'imageData'
|
||||||
|
);
|
||||||
|
|
||||||
const manifestProto = new window.textsecure.protobuf.StickerPack();
|
const manifestProto = new window.textsecure.protobuf.StickerPack();
|
||||||
manifestProto.title = manifest.title;
|
manifestProto.title = manifest.title;
|
||||||
|
@ -133,7 +202,7 @@ window.encryptAndUpload = async (
|
||||||
);
|
);
|
||||||
const encryptedStickers = await pMap(
|
const encryptedStickers = await pMap(
|
||||||
uniqueStickers,
|
uniqueStickers,
|
||||||
({ webp }) => encrypt(webp.buffer, encryptionKey, iv),
|
({ imageData }) => encrypt(imageData.buffer, encryptionKey, iv),
|
||||||
{
|
{
|
||||||
concurrency: 3,
|
concurrency: 3,
|
||||||
timeout: 1000 * 60 * 2,
|
timeout: 1000 * 60 * 2,
|
||||||
|
|
|
@ -15,18 +15,24 @@ import { bindActionCreators } from 'redux';
|
||||||
import arrayMove from 'array-move';
|
import arrayMove from 'array-move';
|
||||||
// eslint-disable-next-line import/no-cycle
|
// eslint-disable-next-line import/no-cycle
|
||||||
import { AppState } from '../reducer';
|
import { AppState } from '../reducer';
|
||||||
import { PackMetaData, WebpData, StickerData } from '../../util/preload';
|
import {
|
||||||
|
PackMetaData,
|
||||||
|
StickerImageData,
|
||||||
|
StickerData,
|
||||||
|
} from '../../util/preload';
|
||||||
import { EmojiPickDataType } from '../../../ts/components/emoji/EmojiPicker';
|
import { EmojiPickDataType } from '../../../ts/components/emoji/EmojiPicker';
|
||||||
import { convertShortName } from '../../../ts/components/emoji/lib';
|
import { convertShortName } from '../../../ts/components/emoji/lib';
|
||||||
|
|
||||||
export const initializeStickers = createAction<Array<string>>(
|
export const initializeStickers = createAction<Array<string>>(
|
||||||
'stickers/initializeStickers'
|
'stickers/initializeStickers'
|
||||||
);
|
);
|
||||||
export const addWebp = createAction<WebpData>('stickers/addSticker');
|
export const addImageData = createAction<StickerImageData>(
|
||||||
|
'stickers/addSticker'
|
||||||
|
);
|
||||||
export const removeSticker = createAction<string>('stickers/removeSticker');
|
export const removeSticker = createAction<string>('stickers/removeSticker');
|
||||||
export const moveSticker = createAction<SortEnd>('stickers/moveSticker');
|
export const moveSticker = createAction<SortEnd>('stickers/moveSticker');
|
||||||
export const setCover = createAction<WebpData>('stickers/setCover');
|
export const setCover = createAction<StickerImageData>('stickers/setCover');
|
||||||
export const resetCover = createAction<WebpData>('stickers/resetCover');
|
export const resetCover = createAction<StickerImageData>('stickers/resetCover');
|
||||||
export const setEmoji = createAction<{ id: string; emoji: EmojiPickDataType }>(
|
export const setEmoji = createAction<{ id: string; emoji: EmojiPickDataType }>(
|
||||||
'stickers/setEmoji'
|
'stickers/setEmoji'
|
||||||
);
|
);
|
||||||
|
@ -48,7 +54,7 @@ export const maxStickers = 200;
|
||||||
export const maxByteSize = 100 * 1024;
|
export const maxByteSize = 100 * 1024;
|
||||||
|
|
||||||
interface StateStickerData {
|
interface StateStickerData {
|
||||||
readonly webp?: WebpData;
|
readonly imageData?: StickerImageData;
|
||||||
readonly emoji?: EmojiPickDataType;
|
readonly emoji?: EmojiPickDataType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -59,7 +65,7 @@ interface StateToastData {
|
||||||
|
|
||||||
export type State = {
|
export type State = {
|
||||||
readonly order: Array<string>;
|
readonly order: Array<string>;
|
||||||
readonly cover?: WebpData;
|
readonly cover?: StickerImageData;
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
readonly author: string;
|
readonly author: string;
|
||||||
readonly packId: string;
|
readonly packId: string;
|
||||||
|
@ -71,7 +77,7 @@ export type State = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Actions = {
|
export type Actions = {
|
||||||
addWebp: typeof addWebp;
|
addImageData: typeof addImageData;
|
||||||
initializeStickers: typeof initializeStickers;
|
initializeStickers: typeof initializeStickers;
|
||||||
removeSticker: typeof removeSticker;
|
removeSticker: typeof removeSticker;
|
||||||
moveSticker: typeof moveSticker;
|
moveSticker: typeof moveSticker;
|
||||||
|
@ -100,7 +106,7 @@ const adjustCover = (state: Draft<State>) => {
|
||||||
const first = state.order[0];
|
const first = state.order[0];
|
||||||
|
|
||||||
if (first) {
|
if (first) {
|
||||||
state.cover = state.data[first].webp;
|
state.cover = state.data[first].imageData;
|
||||||
} else {
|
} else {
|
||||||
delete state.cover;
|
delete state.cover;
|
||||||
}
|
}
|
||||||
|
@ -121,7 +127,7 @@ export const reducer = reduceReducers<State>(
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
handleAction(addWebp, (state, { payload }) => {
|
handleAction(addImageData, (state, { payload }) => {
|
||||||
if (isNumber(payload.meta.pages)) {
|
if (isNumber(payload.meta.pages)) {
|
||||||
state.toasts.push({ key: 'StickerCreator--Toasts--animated' });
|
state.toasts.push({ key: 'StickerCreator--Toasts--animated' });
|
||||||
pull(state.order, payload.path);
|
pull(state.order, payload.path);
|
||||||
|
@ -133,9 +139,9 @@ export const reducer = reduceReducers<State>(
|
||||||
} else {
|
} else {
|
||||||
const data = state.data[payload.path];
|
const data = state.data[payload.path];
|
||||||
|
|
||||||
// If we are adding webp data, proceed to update the state and add/update a toast
|
// If we are adding image data, proceed to update the state and add/update a toast
|
||||||
if (data && !data.webp) {
|
if (data && !data.imageData) {
|
||||||
data.webp = payload;
|
data.imageData = payload;
|
||||||
|
|
||||||
const key = 'StickerCreator--Toasts--imagesAdded';
|
const key = 'StickerCreator--Toasts--imagesAdded';
|
||||||
|
|
||||||
|
@ -223,7 +229,7 @@ export const useTitle = (): string =>
|
||||||
export const useAuthor = (): string =>
|
export const useAuthor = (): string =>
|
||||||
useSelector(({ stickers }: AppState) => stickers.author);
|
useSelector(({ stickers }: AppState) => stickers.author);
|
||||||
|
|
||||||
export const useCover = (): WebpData | undefined =>
|
export const useCover = (): StickerImageData | undefined =>
|
||||||
useSelector(({ stickers }: AppState) => stickers.cover);
|
useSelector(({ stickers }: AppState) => stickers.cover);
|
||||||
|
|
||||||
export const useStickerOrder = (): Array<string> =>
|
export const useStickerOrder = (): Array<string> =>
|
||||||
|
@ -237,7 +243,7 @@ export const useStickersReady = (): boolean =>
|
||||||
({ stickers }: AppState) =>
|
({ stickers }: AppState) =>
|
||||||
stickers.order.length >= minStickers &&
|
stickers.order.length >= minStickers &&
|
||||||
stickers.order.length <= maxStickers &&
|
stickers.order.length <= maxStickers &&
|
||||||
Object.values(stickers.data).every(({ webp }) => !!webp)
|
Object.values(stickers.data).every(({ imageData }) => Boolean(imageData))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const useEmojisReady = (): boolean =>
|
export const useEmojisReady = (): boolean =>
|
||||||
|
@ -288,7 +294,7 @@ export const useSelectOrderedData = (): Array<StickerData> =>
|
||||||
useSelector(selectOrderedData);
|
useSelector(selectOrderedData);
|
||||||
|
|
||||||
const selectOrderedImagePaths = createSelector(selectOrderedData, data =>
|
const selectOrderedImagePaths = createSelector(selectOrderedData, data =>
|
||||||
data.map(({ webp }) => (webp as WebpData).src)
|
data.map(({ imageData }) => imageData.src)
|
||||||
);
|
);
|
||||||
|
|
||||||
export const useOrderedImagePaths = (): Array<string> =>
|
export const useOrderedImagePaths = (): Array<string> =>
|
||||||
|
@ -301,7 +307,7 @@ export const useStickerActions = (): Actions => {
|
||||||
() =>
|
() =>
|
||||||
bindActionCreators(
|
bindActionCreators(
|
||||||
{
|
{
|
||||||
addWebp,
|
addImageData,
|
||||||
initializeStickers,
|
initializeStickers,
|
||||||
removeSticker,
|
removeSticker,
|
||||||
moveSticker,
|
moveSticker,
|
||||||
|
|
|
@ -2,32 +2,28 @@ import { Metadata } from 'sharp';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
convertToWebp: ConvertToWebpFn;
|
processStickerImage: ProcessStickerImageFn;
|
||||||
encryptAndUpload: EncryptAndUploadFn;
|
encryptAndUpload: EncryptAndUploadFn;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WebpData = {
|
export type StickerImageData = {
|
||||||
buffer: Buffer;
|
buffer: Buffer;
|
||||||
src: string;
|
src: string;
|
||||||
path: string;
|
path: string;
|
||||||
meta: Metadata & { pages?: number }; // Pages is not currently in the sharp metadata type
|
meta: Metadata & { pages?: number }; // Pages is not currently in the sharp metadata type
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConvertToWebpFn = (
|
type ProcessStickerImageFn = (path: string) => Promise<StickerImageData>;
|
||||||
path: string,
|
|
||||||
width?: number,
|
|
||||||
height?: number
|
|
||||||
) => Promise<WebpData>;
|
|
||||||
|
|
||||||
export type StickerData = { webp?: WebpData; emoji?: string };
|
export type StickerData = { imageData?: StickerImageData; emoji?: string };
|
||||||
export type PackMetaData = { packId: string; key: string };
|
export type PackMetaData = { packId: string; key: string };
|
||||||
|
|
||||||
export type EncryptAndUploadFn = (
|
export type EncryptAndUploadFn = (
|
||||||
manifest: { title: string; author: string },
|
manifest: { title: string; author: string },
|
||||||
stickers: Array<StickerData>,
|
stickers: Array<StickerData>,
|
||||||
cover: WebpData,
|
cover: StickerImageData,
|
||||||
onProgress?: () => unknown
|
onProgress?: () => unknown
|
||||||
) => Promise<PackMetaData>;
|
) => Promise<PackMetaData>;
|
||||||
|
|
||||||
export const { encryptAndUpload, convertToWebp } = window;
|
export const { encryptAndUpload, processStickerImage } = window;
|
||||||
|
|
16
sticker-creator/util/useStickerDropzone.ts
Normal file
16
sticker-creator/util/useStickerDropzone.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { useDropzone, DropzoneOptions } from 'react-dropzone';
|
||||||
|
|
||||||
|
export const useStickerDropzone = (
|
||||||
|
onDrop: DropzoneOptions['onDrop']
|
||||||
|
): ReturnType<typeof useDropzone> =>
|
||||||
|
useDropzone({
|
||||||
|
onDrop,
|
||||||
|
accept: [
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
// Some OSes recognize .apng files with the MIME type but others don't, so we supply
|
||||||
|
// the extension too.
|
||||||
|
'image/apng',
|
||||||
|
'.apng',
|
||||||
|
],
|
||||||
|
});
|
|
@ -14,6 +14,8 @@ import {
|
||||||
} from '../state/ducks/conversations';
|
} from '../state/ducks/conversations';
|
||||||
import { ColorType } from '../types/Colors';
|
import { ColorType } from '../types/Colors';
|
||||||
import { MessageModel } from './messages';
|
import { MessageModel } from './messages';
|
||||||
|
import { sniffImageMimeType } from '../util/sniffImageMimeType';
|
||||||
|
import { MIMEType, IMAGE_WEBP } from '../types/MIME';
|
||||||
|
|
||||||
/* eslint-disable more/no-then */
|
/* eslint-disable more/no-then */
|
||||||
window.Whisper = window.Whisper || {};
|
window.Whisper = window.Whisper || {};
|
||||||
|
@ -1773,6 +1775,23 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
const { path, width, height } = stickerData;
|
const { path, width, height } = stickerData;
|
||||||
const arrayBuffer = await readStickerData(path);
|
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 = {
|
const sticker = {
|
||||||
packId,
|
packId,
|
||||||
stickerId,
|
stickerId,
|
||||||
|
@ -1780,7 +1799,7 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
data: {
|
data: {
|
||||||
size: arrayBuffer.byteLength,
|
size: arrayBuffer.byteLength,
|
||||||
data: arrayBuffer,
|
data: arrayBuffer,
|
||||||
contentType: 'image/webp',
|
contentType,
|
||||||
width,
|
width,
|
||||||
height,
|
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_JPEG = 'image/jpeg' as MIMEType;
|
||||||
export const IMAGE_PNG = 'image/png' as MIMEType;
|
export const IMAGE_PNG = 'image/png' as MIMEType;
|
||||||
export const IMAGE_WEBP = 'image/webp' 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_MP4 = 'video/mp4' as MIMEType;
|
||||||
export const VIDEO_QUICKTIME = 'video/quicktime' as MIMEType;
|
export const VIDEO_QUICKTIME = 'video/quicktime' as MIMEType;
|
||||||
export const LONG_MESSAGE = 'text/x-signal-plain' 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…
Reference in a new issue