mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-05-08 17:08:57 +01:00
Support APNGs in Sticker Creator
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user