Support APNGs in Sticker Creator

This commit is contained in:
Evan Hahn
2020-09-28 13:40:26 -05:00
committed by Josh Perez
parent 6b3d5c19b3
commit bdd71e4898
20 changed files with 542 additions and 62 deletions
+8 -8
View File
@@ -1,8 +1,9 @@
import * as React from 'react';
import { FileWithPath, useDropzone } from 'react-dropzone';
import { FileWithPath } from 'react-dropzone';
import { AppStage } from './AppStage';
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 { H2, Text } from '../../elements/Typography';
import { LabeledInput } from '../../elements/LabeledInput';
@@ -22,8 +23,8 @@ export const MetaStage: React.ComponentType = () => {
const onDrop = React.useCallback(
async ([{ path }]: Array<FileWithPath>) => {
try {
const webp = await convertToWebp(path);
actions.setCover(webp);
const stickerImage = await processStickerImage(path);
actions.setCover(stickerImage);
} catch (e) {
actions.removeSticker(path);
}
@@ -31,10 +32,9 @@ export const MetaStage: React.ComponentType = () => {
[actions]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: ['image/png', 'image/webp'],
});
const { getRootProps, getInputProps, isDragActive } = useStickerDropzone(
onDrop
);
const onNext = React.useCallback(() => {
setConfirming(true);
+7 -5
View File
@@ -9,7 +9,7 @@ import * as styles from './StickerGrid.scss';
import { Props as StickerFrameProps, StickerFrame } from './StickerFrame';
import { stickersDuck } from '../store';
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 });
@@ -17,7 +17,7 @@ const SmartStickerFrame = SortableElement(
({ id, showGuide, mode }: StickerFrameProps) => {
const data = stickersDuck.useStickerData(id);
const actions = stickersDuck.useStickerActions();
const image = data.webp ? data.webp.src : undefined;
const image = data.imageData ? data.imageData.src : undefined;
return (
<StickerFrame
@@ -52,13 +52,15 @@ const InnerGrid = SortableContainer(
paths.forEach(path => {
queue.add(async () => {
try {
const webp = await convertToWebp(path);
actions.addWebp(webp);
const stickerImage = await processStickerImage(path);
actions.addImageData(stickerImage);
} catch (e) {
window.log.error('Error processing image:', e);
actions.removeSticker(path);
actions.addToast({
key: 'StickerCreator--Toasts--errorProcessing',
key:
(e || {}).errorMessageI18nKey ||
'StickerCreator--Toasts--errorProcessing',
});
}
});
+5 -5
View File
@@ -1,7 +1,8 @@
import * as React from 'react';
import { useDropzone, FileWithPath } from 'react-dropzone';
import { FileWithPath } from 'react-dropzone';
import * as styles from './DropZone.scss';
import { useI18n } from '../util/i18n';
import { useStickerDropzone } from '../util/useStickerDropzone';
export type Props = {
readonly inner?: boolean;
@@ -32,10 +33,9 @@ export const DropZone: React.ComponentType<Props> = props => {
[onDrop]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: handleDrop,
accept: ['image/png', 'image/webp'],
});
const { getRootProps, getInputProps, isDragActive } = useStickerDropzone(
handleDrop
);
React.useEffect(() => {
if (onDragActive) {
+83 -14
View File
@@ -12,6 +12,11 @@ const { makeGetter } = require('../preload_utils');
const { dialog } = remote;
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.PROTO_ROOT = '../../protos';
window.getEnvironment = () => config.environment;
@@ -32,6 +37,9 @@ window.Signal = Signal.setup({});
window.textsecure = require('../ts/textsecure').default;
const { initialize: initializeWebAPI } = require('../ts/textsecure/WebAPI');
const {
getAnimatedPngDataIfExists,
} = require('../ts/util/getAnimatedPngDataIfExists');
const WebAPI = initializeWebAPI({
url: config.serverUrl,
@@ -49,25 +57,83 @@ const WebAPI = initializeWebAPI({
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 sharpImg = sharp(imgBuffer);
const meta = await sharpImg.metadata();
const buffer = await sharpImg
.resize({
width,
height,
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.webp()
.toBuffer();
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({
width: STICKER_SIZE,
height: STICKER_SIZE,
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.webp()
.toBuffer();
}
return {
path,
buffer,
src: `data:image/webp;base64,${buffer.toString('base64')}`,
buffer: processedBuffer,
src: `data:${contentType};base64,${processedBuffer.toString('base64')}`,
meta,
};
};
@@ -108,7 +174,10 @@ window.encryptAndUpload = async (
password,
});
const uniqueStickers = uniqBy([...stickers, { webp: cover }], 'webp');
const uniqueStickers = uniqBy(
[...stickers, { imageData: cover }],
'imageData'
);
const manifestProto = new window.textsecure.protobuf.StickerPack();
manifestProto.title = manifest.title;
@@ -133,7 +202,7 @@ window.encryptAndUpload = async (
);
const encryptedStickers = await pMap(
uniqueStickers,
({ webp }) => encrypt(webp.buffer, encryptionKey, iv),
({ imageData }) => encrypt(imageData.buffer, encryptionKey, iv),
{
concurrency: 3,
timeout: 1000 * 60 * 2,
+22 -16
View File
@@ -15,18 +15,24 @@ import { bindActionCreators } from 'redux';
import arrayMove from 'array-move';
// eslint-disable-next-line import/no-cycle
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 { convertShortName } from '../../../ts/components/emoji/lib';
export const initializeStickers = createAction<Array<string>>(
'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 moveSticker = createAction<SortEnd>('stickers/moveSticker');
export const setCover = createAction<WebpData>('stickers/setCover');
export const resetCover = createAction<WebpData>('stickers/resetCover');
export const setCover = createAction<StickerImageData>('stickers/setCover');
export const resetCover = createAction<StickerImageData>('stickers/resetCover');
export const setEmoji = createAction<{ id: string; emoji: EmojiPickDataType }>(
'stickers/setEmoji'
);
@@ -48,7 +54,7 @@ export const maxStickers = 200;
export const maxByteSize = 100 * 1024;
interface StateStickerData {
readonly webp?: WebpData;
readonly imageData?: StickerImageData;
readonly emoji?: EmojiPickDataType;
}
@@ -59,7 +65,7 @@ interface StateToastData {
export type State = {
readonly order: Array<string>;
readonly cover?: WebpData;
readonly cover?: StickerImageData;
readonly title: string;
readonly author: string;
readonly packId: string;
@@ -71,7 +77,7 @@ export type State = {
};
export type Actions = {
addWebp: typeof addWebp;
addImageData: typeof addImageData;
initializeStickers: typeof initializeStickers;
removeSticker: typeof removeSticker;
moveSticker: typeof moveSticker;
@@ -100,7 +106,7 @@ const adjustCover = (state: Draft<State>) => {
const first = state.order[0];
if (first) {
state.cover = state.data[first].webp;
state.cover = state.data[first].imageData;
} else {
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)) {
state.toasts.push({ key: 'StickerCreator--Toasts--animated' });
pull(state.order, payload.path);
@@ -133,9 +139,9 @@ export const reducer = reduceReducers<State>(
} else {
const data = state.data[payload.path];
// If we are adding webp data, proceed to update the state and add/update a toast
if (data && !data.webp) {
data.webp = payload;
// If we are adding image data, proceed to update the state and add/update a toast
if (data && !data.imageData) {
data.imageData = payload;
const key = 'StickerCreator--Toasts--imagesAdded';
@@ -223,7 +229,7 @@ export const useTitle = (): string =>
export const useAuthor = (): string =>
useSelector(({ stickers }: AppState) => stickers.author);
export const useCover = (): WebpData | undefined =>
export const useCover = (): StickerImageData | undefined =>
useSelector(({ stickers }: AppState) => stickers.cover);
export const useStickerOrder = (): Array<string> =>
@@ -237,7 +243,7 @@ export const useStickersReady = (): boolean =>
({ stickers }: AppState) =>
stickers.order.length >= minStickers &&
stickers.order.length <= maxStickers &&
Object.values(stickers.data).every(({ webp }) => !!webp)
Object.values(stickers.data).every(({ imageData }) => Boolean(imageData))
);
export const useEmojisReady = (): boolean =>
@@ -288,7 +294,7 @@ export const useSelectOrderedData = (): Array<StickerData> =>
useSelector(selectOrderedData);
const selectOrderedImagePaths = createSelector(selectOrderedData, data =>
data.map(({ webp }) => (webp as WebpData).src)
data.map(({ imageData }) => imageData.src)
);
export const useOrderedImagePaths = (): Array<string> =>
@@ -301,7 +307,7 @@ export const useStickerActions = (): Actions => {
() =>
bindActionCreators(
{
addWebp,
addImageData,
initializeStickers,
removeSticker,
moveSticker,
+6 -10
View File
@@ -2,32 +2,28 @@ import { Metadata } from 'sharp';
declare global {
interface Window {
convertToWebp: ConvertToWebpFn;
processStickerImage: ProcessStickerImageFn;
encryptAndUpload: EncryptAndUploadFn;
}
}
export type WebpData = {
export type StickerImageData = {
buffer: Buffer;
src: string;
path: string;
meta: Metadata & { pages?: number }; // Pages is not currently in the sharp metadata type
};
export type ConvertToWebpFn = (
path: string,
width?: number,
height?: number
) => Promise<WebpData>;
type ProcessStickerImageFn = (path: string) => Promise<StickerImageData>;
export type StickerData = { webp?: WebpData; emoji?: string };
export type StickerData = { imageData?: StickerImageData; emoji?: string };
export type PackMetaData = { packId: string; key: string };
export type EncryptAndUploadFn = (
manifest: { title: string; author: string },
stickers: Array<StickerData>,
cover: WebpData,
cover: StickerImageData,
onProgress?: () => unknown
) => Promise<PackMetaData>;
export const { encryptAndUpload, convertToWebp } = window;
export const { encryptAndUpload, processStickerImage } = window;
@@ -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',
],
});