diff --git a/.eslintignore b/.eslintignore index d19483f72..ccd6fb019 100644 --- a/.eslintignore +++ b/.eslintignore @@ -25,6 +25,7 @@ test/blanket_mocha.js # TypeScript generated files ts/**/*.js sticker-creator/**/*.js +!sticker-creator/preload.js **/*.d.ts webpack.config.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 903689057..60d392366 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2331,7 +2331,7 @@ "description": "Title for the drop stage of the sticker creator" }, "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" }, "StickerCreator--DropStage--showMargins": { @@ -2460,7 +2460,23 @@ }, "StickerCreator--Toasts--errorProcessing": { "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": { "message": "Error uploading stickers: $message$", diff --git a/fixtures/2x2.bmp b/fixtures/2x2.bmp new file mode 100644 index 000000000..e32f57cb6 Binary files /dev/null and b/fixtures/2x2.bmp differ diff --git a/fixtures/Animated_PNG_example_bouncing_beach_ball.png b/fixtures/Animated_PNG_example_bouncing_beach_ball.png new file mode 100644 index 000000000..1b35084a3 Binary files /dev/null and b/fixtures/Animated_PNG_example_bouncing_beach_ball.png differ diff --git a/fixtures/apng_with_2_plays.png b/fixtures/apng_with_2_plays.png new file mode 100644 index 000000000..b8e06d173 Binary files /dev/null and b/fixtures/apng_with_2_plays.png differ diff --git a/fixtures/kitten-1-64-64.ico b/fixtures/kitten-1-64-64.ico new file mode 100644 index 000000000..e8bf585e4 Binary files /dev/null and b/fixtures/kitten-1-64-64.ico differ diff --git a/js/modules/types/message.js b/js/modules/types/message.js index 312e74ff1..484a8167a 100644 --- a/js/modules/types/message.js +++ b/js/modules/types/message.js @@ -508,7 +508,6 @@ exports.processNewSticker = async ( }); return { - contentType: 'image/webp', path, width, height, diff --git a/sticker-creator/app/stages/MetaStage.tsx b/sticker-creator/app/stages/MetaStage.tsx index e5aabc519..59881068c 100644 --- a/sticker-creator/app/stages/MetaStage.tsx +++ b/sticker-creator/app/stages/MetaStage.tsx @@ -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) => { 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); diff --git a/sticker-creator/components/StickerGrid.tsx b/sticker-creator/components/StickerGrid.tsx index 819288086..0d6b08ee9 100644 --- a/sticker-creator/components/StickerGrid.tsx +++ b/sticker-creator/components/StickerGrid.tsx @@ -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 ( { 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', }); } }); diff --git a/sticker-creator/elements/DropZone.tsx b/sticker-creator/elements/DropZone.tsx index 036e22225..c4b319332 100644 --- a/sticker-creator/elements/DropZone.tsx +++ b/sticker-creator/elements/DropZone.tsx @@ -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 => { [onDrop] ); - const { getRootProps, getInputProps, isDragActive } = useDropzone({ - onDrop: handleDrop, - accept: ['image/png', 'image/webp'], - }); + const { getRootProps, getInputProps, isDragActive } = useStickerDropzone( + handleDrop + ); React.useEffect(() => { if (onDragActive) { diff --git a/sticker-creator/preload.js b/sticker-creator/preload.js index b75067501..a445e41c6 100644 --- a/sticker-creator/preload.js +++ b/sticker-creator/preload.js @@ -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, diff --git a/sticker-creator/store/ducks/stickers.ts b/sticker-creator/store/ducks/stickers.ts index b67422631..f7041a6b8 100644 --- a/sticker-creator/store/ducks/stickers.ts +++ b/sticker-creator/store/ducks/stickers.ts @@ -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>( 'stickers/initializeStickers' ); -export const addWebp = createAction('stickers/addSticker'); +export const addImageData = createAction( + 'stickers/addSticker' +); export const removeSticker = createAction('stickers/removeSticker'); export const moveSticker = createAction('stickers/moveSticker'); -export const setCover = createAction('stickers/setCover'); -export const resetCover = createAction('stickers/resetCover'); +export const setCover = createAction('stickers/setCover'); +export const resetCover = createAction('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; - 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) => { 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( }); }), - 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( } 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 => @@ -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 => useSelector(selectOrderedData); const selectOrderedImagePaths = createSelector(selectOrderedData, data => - data.map(({ webp }) => (webp as WebpData).src) + data.map(({ imageData }) => imageData.src) ); export const useOrderedImagePaths = (): Array => @@ -301,7 +307,7 @@ export const useStickerActions = (): Actions => { () => bindActionCreators( { - addWebp, + addImageData, initializeStickers, removeSticker, moveSticker, diff --git a/sticker-creator/util/preload.ts b/sticker-creator/util/preload.ts index ebb5c0479..c52a1a9c1 100644 --- a/sticker-creator/util/preload.ts +++ b/sticker-creator/util/preload.ts @@ -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; +type ProcessStickerImageFn = (path: string) => Promise; -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, - cover: WebpData, + cover: StickerImageData, onProgress?: () => unknown ) => Promise; -export const { encryptAndUpload, convertToWebp } = window; +export const { encryptAndUpload, processStickerImage } = window; diff --git a/sticker-creator/util/useStickerDropzone.ts b/sticker-creator/util/useStickerDropzone.ts new file mode 100644 index 000000000..d3f849d65 --- /dev/null +++ b/sticker-creator/util/useStickerDropzone.ts @@ -0,0 +1,16 @@ +import { useDropzone, DropzoneOptions } from 'react-dropzone'; + +export const useStickerDropzone = ( + onDrop: DropzoneOptions['onDrop'] +): ReturnType => + 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', + ], + }); diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index d0f62abca..3c25912c8 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -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 `` instead of a + // `