Sticker creator updates: new 200 sticker max, WebP supported

This commit is contained in:
Ken Powers 2019-12-19 18:27:02 -05:00 committed by Scott Nonnenberg
parent 94f52edbd0
commit fe65fd3eaa
15 changed files with 148 additions and 31 deletions

View file

@ -2212,7 +2212,7 @@
}, },
"StickerCreator--DropStage--help": { "StickerCreator--DropStage--help": {
"message": "message":
"Stickers must be in PNG format with a transparent background and 512x512 pixels. Recommended margin is 16px.", "Stickers must be in PNG 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": {
@ -2220,6 +2220,16 @@
"description": "description":
"Text for the show margins toggle on the drop stage of the sticker creator" "Text for the show margins toggle on the drop stage of the sticker creator"
}, },
"StickerCreator--DropStage--addMore": {
"message": "Add $count$ or more",
"description": "Text to show user how many more stickers they must add",
"placeholders": {
"hashtag": {
"content": "$1",
"example": "4"
}
}
},
"StickerCreator--EmojiStage--title": { "StickerCreator--EmojiStage--title": {
"message": "Add an emoji to each sticker", "message": "Add an emoji to each sticker",
"description": "Title for the drop stage of the sticker creator" "description": "Title for the drop stage of the sticker creator"
@ -2340,6 +2350,11 @@
} }
} }
}, },
"StickerCreator--Toasts--animated": {
"message": "Animated stickers are not currently supported",
"description":
"Text for the toast when an image that is animated was dropped"
},
"StickerCreator--Toasts--tooLarge": { "StickerCreator--Toasts--tooLarge": {
"message": "Dropped image is too large", "message": "Dropped image is too large",
"description": "description":

View file

@ -6,7 +6,7 @@ const { Agent } = require('https');
const is = require('@sindresorhus/is'); const is = require('@sindresorhus/is');
const { redactPackId } = require('./stickers'); const { redactPackId } = require('./stickers');
/* global Signal, Buffer, setTimeout, log, _, getGuid */ /* global Signal, Buffer, setTimeout, log, _, getGuid, PQueue */
/* eslint-disable more/no-then, no-bitwise, no-nested-ternary */ /* eslint-disable more/no-then, no-bitwise, no-nested-ternary */
@ -936,7 +936,6 @@ function initialize({
// This is going to the CDN, not the service, so we use _outerAjax // This is going to the CDN, not the service, so we use _outerAjax
await _outerAjax(`${cdnUrl}/`, { await _outerAjax(`${cdnUrl}/`, {
...manifestParams, ...manifestParams,
key: 'stickers/asdfasdf/manifest.proto',
certificateAuthority, certificateAuthority,
proxyUrl, proxyUrl,
timeout: 0, timeout: 0,
@ -945,17 +944,20 @@ function initialize({
}); });
// Upload stickers // Upload stickers
const queue = new PQueue({ concurrency: 3 });
await Promise.all( await Promise.all(
stickers.map(async (s, id) => { stickers.map(async (s, id) => {
const stickerParams = makePutParams(s, encryptedStickers[id]); const stickerParams = makePutParams(s, encryptedStickers[id]);
await _outerAjax(`${cdnUrl}/`, { await queue.add(async () =>
...stickerParams, _outerAjax(`${cdnUrl}/`, {
certificateAuthority, ...stickerParams,
proxyUrl, certificateAuthority,
timeout: 0, proxyUrl,
type: 'POST', timeout: 0,
processData: false, type: 'POST',
}); processData: false,
})
);
if (onProgress) { if (onProgress) {
onProgress(); onProgress();
} }

View file

@ -131,7 +131,7 @@
"testcheck": "1.0.0-rc.2", "testcheck": "1.0.0-rc.2",
"tmp": "0.0.33", "tmp": "0.0.33",
"to-arraybuffer": "1.0.1", "to-arraybuffer": "1.0.1",
"typeface-inter": "^3.10.0", "typeface-inter": "3.10.0",
"underscore": "1.9.0", "underscore": "1.9.0",
"uuid": "3.3.2", "uuid": "3.3.2",
"websocket": "1.0.28" "websocket": "1.0.28"
@ -141,7 +141,7 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.5.5", "@babel/core": "7.5.5",
"@babel/plugin-proposal-class-properties": "^7.7.4", "@babel/plugin-proposal-class-properties": "7.7.4",
"@babel/preset-react": "7.0.0", "@babel/preset-react": "7.0.0",
"@babel/preset-typescript": "7.3.3", "@babel/preset-typescript": "7.3.3",
"@storybook/addon-actions": "5.1.11", "@storybook/addon-actions": "5.1.11",
@ -177,6 +177,7 @@
"@types/redux-logger": "3.0.7", "@types/redux-logger": "3.0.7",
"@types/rimraf": "2.0.2", "@types/rimraf": "2.0.2",
"@types/semver": "5.5.0", "@types/semver": "5.5.0",
"@types/sharp": "0.23.1",
"@types/sinon": "4.3.1", "@types/sinon": "4.3.1",
"@types/storybook__addon-actions": "3.4.3", "@types/storybook__addon-actions": "3.4.3",
"@types/storybook__addon-knobs": "5.0.3", "@types/storybook__addon-knobs": "5.0.3",
@ -265,7 +266,10 @@
"bundleVersion": "1" "bundleVersion": "1"
}, },
"win": { "win": {
"asarUnpack": ["node_modules/spellchecker/vendor/hunspell_dictionaries", "node_modules/sharp"], "asarUnpack": [
"node_modules/spellchecker/vendor/hunspell_dictionaries",
"node_modules/sharp"
],
"artifactName": "${name}-win-${version}.${ext}", "artifactName": "${name}-win-${version}.${ext}",
"certificateSubjectName": "Signal", "certificateSubjectName": "Signal",
"publisherName": "Signal (Quiet Riddle Ventures, LLC)", "publisherName": "Signal (Quiet Riddle Ventures, LLC)",
@ -298,7 +302,10 @@
"desktop": { "desktop": {
"StartupWMClass": "Signal" "StartupWMClass": "Signal"
}, },
"asarUnpack": ["node_modules/spellchecker/vendor/hunspell_dictionaries", "node_modules/sharp"], "asarUnpack": [
"node_modules/spellchecker/vendor/hunspell_dictionaries",
"node_modules/sharp"
],
"target": [ "target": [
"deb" "deb"
], ],

View file

@ -29,6 +29,12 @@
align-items: center; align-items: center;
} }
.footer-right {
display: flex;
flex-direction: row;
align-items: center;
}
.button { .button {
margin-left: 12px; margin-left: 12px;
} }

View file

@ -3,6 +3,8 @@ import * as styles from './AppStage.scss';
import { history } from '../../util/history'; import { history } from '../../util/history';
import { Button } from '../../elements/Button'; import { Button } from '../../elements/Button';
import { useI18n } from '../../util/i18n'; import { useI18n } from '../../util/i18n';
import { Text } from '../../elements/Typography';
import { stickersDuck } from '../../store';
export type Props = { export type Props = {
readonly children: React.ReactNode; readonly children: React.ReactNode;
@ -56,6 +58,8 @@ export const AppStage = (props: Props) => {
[prev] [prev]
); );
const addMoreCount = stickersDuck.useAddMoreCount();
return ( return (
<> <>
<main className={getClassName(props)}>{children}</main> <main className={getClassName(props)}>{children}</main>
@ -65,6 +69,11 @@ export const AppStage = (props: Props) => {
{prevText || i18n('StickerCreator--AppStage--prev')} {prevText || i18n('StickerCreator--AppStage--prev')}
</Button> </Button>
) : null} ) : null}
{addMoreCount > 0 ? (
<Text secondary={true}>
{i18n('StickerCreator--DropStage--addMore', [addMoreCount])}
</Text>
) : null}
{next || onNext ? ( {next || onNext ? (
<Button <Button
className={styles.button} className={styles.button}

View file

@ -10,16 +10,28 @@ import { stickersDuck } from '../../store';
import { useI18n } from '../../util/i18n'; import { useI18n } from '../../util/i18n';
const renderToaster = ({ const renderToaster = ({
hasAnimated,
hasTooLarge, hasTooLarge,
numberAdded, numberAdded,
resetStatus, resetStatus,
i18n, i18n,
}: { }: {
hasAnimated: boolean;
hasTooLarge: boolean; hasTooLarge: boolean;
numberAdded: number; numberAdded: number;
resetStatus: () => unknown; resetStatus: () => unknown;
i18n: ReturnType<typeof useI18n>; i18n: ReturnType<typeof useI18n>;
}) => { }) => {
if (hasAnimated) {
return (
<div className={appStyles.toaster}>
<Toast onClick={resetStatus}>
{i18n('StickerCreator--Toasts--animated')}
</Toast>
</div>
);
}
if (hasTooLarge) { if (hasTooLarge) {
return ( return (
<div className={appStyles.toaster}> <div className={appStyles.toaster}>
@ -48,6 +60,7 @@ export const DropStage = () => {
const stickerPaths = stickersDuck.useStickerOrder(); const stickerPaths = stickersDuck.useStickerOrder();
const stickersReady = stickersDuck.useStickersReady(); const stickersReady = stickersDuck.useStickersReady();
const haveStickers = stickerPaths.length > 0; const haveStickers = stickerPaths.length > 0;
const hasAnimated = stickersDuck.useHasAnimated();
const hasTooLarge = stickersDuck.useHasTooLarge(); const hasTooLarge = stickersDuck.useHasTooLarge();
const numberAdded = stickersDuck.useImageAddedCount(); const numberAdded = stickersDuck.useImageAddedCount();
const [showGuide, setShowGuide] = React.useState<boolean>(true); const [showGuide, setShowGuide] = React.useState<boolean>(true);
@ -73,7 +86,13 @@ export const DropStage = () => {
<div className={styles.main}> <div className={styles.main}>
<StickerGrid mode="add" showGuide={showGuide} /> <StickerGrid mode="add" showGuide={showGuide} />
</div> </div>
{renderToaster({ hasTooLarge, numberAdded, resetStatus, i18n })} {renderToaster({
hasAnimated,
hasTooLarge,
numberAdded,
resetStatus,
i18n,
})}
</AppStage> </AppStage>
); );
}; };

View file

@ -22,15 +22,19 @@ export const MetaStage = () => {
const onDrop = React.useCallback( const onDrop = React.useCallback(
async ([{ path }]: Array<FileWithPath>) => { async ([{ path }]: Array<FileWithPath>) => {
const webp = await convertToWebp(path); try {
actions.setCover(webp); const webp = await convertToWebp(path);
actions.setCover(webp);
} catch (e) {
actions.removeSticker(path);
}
}, },
[actions] [actions]
); );
const { getRootProps, getInputProps, isDragActive } = useDropzone({ const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop, onDrop,
accept: ['image/png'], accept: ['image/png', 'image/webp'],
}); });
const onNext = React.useCallback( const onNext = React.useCallback(

View file

@ -11,7 +11,7 @@ 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 { convertToWebp } from '../util/preload';
const queue = new PQueue({ concurrency: 5 }); const queue = new PQueue({ concurrency: 3 });
const SmartStickerFrame = SortableElement( const SmartStickerFrame = SortableElement(
({ id, showGuide, mode }: StickerFrameProps) => { ({ id, showGuide, mode }: StickerFrameProps) => {

View file

@ -34,7 +34,7 @@ export const DropZone = (props: Props) => {
const { getRootProps, getInputProps, isDragActive } = useDropzone({ const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: handleDrop, onDrop: handleDrop,
accept: ['image/png'], accept: ['image/png', 'image/webp'],
}); });
React.useEffect( React.useEffect(

View file

@ -63,3 +63,13 @@
composes: text; composes: text;
text-align: center; text-align: center;
} }
.secondary {
@include light-theme() {
color: $color-gray-60;
}
@include dark-theme() {
color: $color-gray-25;
}
}

View file

@ -10,6 +10,7 @@ export type HeadingProps = React.HTMLProps<HTMLHeadingElement>;
export type ParagraphProps = React.HTMLProps<HTMLParagraphElement> & { export type ParagraphProps = React.HTMLProps<HTMLParagraphElement> & {
center?: boolean; center?: boolean;
wide?: boolean; wide?: boolean;
secondary?: boolean;
}; };
export type SpanProps = React.HTMLProps<HTMLSpanElement>; export type SpanProps = React.HTMLProps<HTMLSpanElement>;
@ -30,10 +31,18 @@ export const H2 = React.memo(
); );
export const Text = React.memo( export const Text = React.memo(
({ children, className, center, wide, ...rest }: Props & ParagraphProps) => ( ({
children,
className,
center,
wide,
secondary,
...rest
}: Props & ParagraphProps) => (
<p <p
className={classnames( className={classnames(
center ? styles.textCenter : styles.text, center ? styles.textCenter : styles.text,
secondary ? styles.secondary : null,
className className
)} )}
{...rest} {...rest}

View file

@ -5,6 +5,7 @@ const pify = require('pify');
const { readFile } = require('fs'); const { readFile } = require('fs');
const config = require('url').parse(window.location.toString(), true).query; const config = require('url').parse(window.location.toString(), true).query;
const { noop, uniqBy } = require('lodash'); const { noop, uniqBy } = require('lodash');
const pMap = require('p-map');
const { deriveStickerPackKey } = require('../js/modules/crypto'); const { deriveStickerPackKey } = require('../js/modules/crypto');
const { makeGetter } = require('../preload_utils'); const { makeGetter } = require('../preload_utils');
@ -16,6 +17,7 @@ window.PROTO_ROOT = '../../protos';
window.getEnvironment = () => config.environment; window.getEnvironment = () => config.environment;
window.getVersion = () => config.version; window.getVersion = () => config.version;
window.getGuid = require('uuid/v4'); window.getGuid = require('uuid/v4');
window.PQueue = require('p-queue');
window.localeMessages = ipc.sendSync('locale-data'); window.localeMessages = ipc.sendSync('locale-data');
@ -38,8 +40,11 @@ const WebAPI = initializeWebAPI({
}); });
window.convertToWebp = async (path, width = 512, height = 512) => { window.convertToWebp = async (path, width = 512, height = 512) => {
const pngBuffer = await pify(readFile)(path); const imgBuffer = await pify(readFile)(path);
const buffer = await sharp(pngBuffer) const sharpImg = sharp(imgBuffer);
const meta = await sharpImg.metadata();
const buffer = await sharpImg
.resize({ .resize({
width, width,
height, height,
@ -53,6 +58,7 @@ window.convertToWebp = async (path, width = 512, height = 512) => {
path, path,
buffer, buffer,
src: `data:image/webp;base64,${buffer.toString('base64')}`, src: `data:image/webp;base64,${buffer.toString('base64')}`,
meta,
}; };
}; };
@ -110,8 +116,10 @@ window.encryptAndUpload = async (
encryptionKey, encryptionKey,
iv iv
); );
const encryptedStickers = await Promise.all( const encryptedStickers = await pMap(
uniqueStickers.map(({ webp }) => encrypt(webp.buffer, encryptionKey, iv)) uniqueStickers,
({ webp }) => encrypt(webp.buffer, encryptionKey, iv),
{ concurrency: 3 }
); );
const packId = await server.putStickers( const packId = await server.putStickers(

View file

@ -9,7 +9,7 @@ import {
} from 'redux-ts-utils'; } from 'redux-ts-utils';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { clamp, pull, take, uniq } from 'lodash'; import { clamp, isNumber, pull, take, uniq } from 'lodash';
import { SortEnd } from 'react-sortable-hoc'; import { SortEnd } from 'react-sortable-hoc';
import arrayMove from 'array-move'; import arrayMove from 'array-move';
import { AppState } from '../reducer'; import { AppState } from '../reducer';
@ -24,6 +24,7 @@ export const addWebp = createAction<WebpData>('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<WebpData>('stickers/setCover');
export const resetCover = createAction<WebpData>('stickers/resetCover');
export const setEmoji = createAction<{ id: string; emoji: EmojiPickDataType }>( export const setEmoji = createAction<{ id: string; emoji: EmojiPickDataType }>(
'stickers/setEmoji' 'stickers/setEmoji'
); );
@ -34,7 +35,7 @@ export const resetStatus = createAction<void>('stickers/resetStatus');
export const reset = createAction<void>('stickers/reset'); export const reset = createAction<void>('stickers/reset');
export const minStickers = 4; export const minStickers = 4;
export const maxStickers = 40; export const maxStickers = 200;
export const maxByteSize = 100 * 1024; export const maxByteSize = 100 * 1024;
export type State = { export type State = {
@ -45,6 +46,7 @@ export type State = {
readonly packId: string; readonly packId: string;
readonly packKey: string; readonly packKey: string;
readonly tooLarge: number; readonly tooLarge: number;
readonly animated: number;
readonly imagesAdded: number; readonly imagesAdded: number;
readonly data: { readonly data: {
readonly [src: string]: { readonly [src: string]: {
@ -62,6 +64,7 @@ const defaultState: State = {
packId: '', packId: '',
packKey: '', packKey: '',
tooLarge: 0, tooLarge: 0,
animated: 0,
imagesAdded: 0, imagesAdded: 0,
}; };
@ -91,7 +94,11 @@ export const reducer = reduceReducers<State>(
}), }),
handleAction(addWebp, (state, { payload }) => { handleAction(addWebp, (state, { payload }) => {
if (payload.buffer.byteLength > maxByteSize) { if (isNumber(payload.meta.pages)) {
state.animated = clamp(state.animated + 1, 0, state.order.length);
pull(state.order, payload.path);
delete state.data[payload.path];
} else if (payload.buffer.byteLength > maxByteSize) {
state.tooLarge = clamp(state.tooLarge + 1, 0, state.order.length); state.tooLarge = clamp(state.tooLarge + 1, 0, state.order.length);
pull(state.order, payload.path); pull(state.order, payload.path);
delete state.data[payload.path]; delete state.data[payload.path];
@ -126,6 +133,10 @@ export const reducer = reduceReducers<State>(
state.cover = payload; state.cover = payload;
}), }),
handleAction(resetCover, state => {
adjustCover(state);
}),
handleAction(setEmoji, (state, { payload }) => { handleAction(setEmoji, (state, { payload }) => {
const data = state.data[payload.id]; const data = state.data[payload.id];
if (data) { if (data) {
@ -148,6 +159,7 @@ export const reducer = reduceReducers<State>(
handleAction(resetStatus, state => { handleAction(resetStatus, state => {
state.tooLarge = 0; state.tooLarge = 0;
state.animated = 0;
state.imagesAdded = 0; state.imagesAdded = 0;
}), }),
@ -202,8 +214,14 @@ const selectUrl = createSelector(
export const usePackUrl = () => useSelector(selectUrl); export const usePackUrl = () => useSelector(selectUrl);
export const useHasTooLarge = () => export const useHasTooLarge = () =>
useSelector(({ stickers }: AppState) => stickers.tooLarge > 0); useSelector(({ stickers }: AppState) => stickers.tooLarge > 0);
export const useHasAnimated = () =>
useSelector(({ stickers }: AppState) => stickers.animated > 0);
export const useImageAddedCount = () => export const useImageAddedCount = () =>
useSelector(({ stickers }: AppState) => stickers.imagesAdded); useSelector(({ stickers }: AppState) => stickers.imagesAdded);
export const useAddMoreCount = () =>
useSelector(({ stickers }: AppState) =>
clamp(minStickers - stickers.order.length, 0, minStickers)
);
const selectOrderedData = createSelector( const selectOrderedData = createSelector(
({ stickers }: AppState) => stickers.order, ({ stickers }: AppState) => stickers.order,

View file

@ -1,7 +1,10 @@
import { Metadata } from 'sharp';
export type WebpData = { export type WebpData = {
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
}; };
export type ConvertToWebpFn = ( export type ConvertToWebpFn = (

View file

@ -334,7 +334,7 @@
"@babel/helper-create-class-features-plugin" "^7.5.5" "@babel/helper-create-class-features-plugin" "^7.5.5"
"@babel/helper-plugin-utils" "^7.0.0" "@babel/helper-plugin-utils" "^7.0.0"
"@babel/plugin-proposal-class-properties@^7.7.4": "@babel/plugin-proposal-class-properties@7.7.4":
version "7.7.4" version "7.7.4"
resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.7.4.tgz#2f964f0cb18b948450362742e33e15211e77c2ba" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.7.4.tgz#2f964f0cb18b948450362742e33e15211e77c2ba"
integrity sha512-EcuXeV4Hv1X3+Q1TsuOmyyxeTRiSqurGJ26+I/FW1WbymmRRapVORm6x1Zl3iDIHyRxEs+VXWp6qnlcfcJSbbw== integrity sha512-EcuXeV4Hv1X3+Q1TsuOmyyxeTRiSqurGJ26+I/FW1WbymmRRapVORm6x1Zl3iDIHyRxEs+VXWp6qnlcfcJSbbw==
@ -2003,6 +2003,13 @@
"@types/express-serve-static-core" "*" "@types/express-serve-static-core" "*"
"@types/mime" "*" "@types/mime" "*"
"@types/sharp@0.23.1":
version "0.23.1"
resolved "https://registry.yarnpkg.com/@types/sharp/-/sharp-0.23.1.tgz#1e02560371d6603adc121389512f0745028aa507"
integrity sha512-iBRM9RjRF9pkIkukk6imlxfaKMRuiRND8L0yYKl5PJu5uLvxuNzp5f0x8aoTG5VX85M8O//BwbttzFVZL1j/FQ==
dependencies:
"@types/node" "*"
"@types/sinon@4.3.1": "@types/sinon@4.3.1":
version "4.3.1" version "4.3.1"
resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-4.3.1.tgz#32458f9b166cd44c23844eee4937814276f35199" resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-4.3.1.tgz#32458f9b166cd44c23844eee4937814276f35199"
@ -15947,7 +15954,7 @@ typedarray@^0.0.6, typedarray@~0.0.5:
version "0.0.6" version "0.0.6"
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
typeface-inter@^3.10.0: typeface-inter@3.10.0:
version "3.10.0" version "3.10.0"
resolved "https://registry.yarnpkg.com/typeface-inter/-/typeface-inter-3.10.0.tgz#04a55d62e2dc3f60db3afab5d8a547e067692bc6" resolved "https://registry.yarnpkg.com/typeface-inter/-/typeface-inter-3.10.0.tgz#04a55d62e2dc3f60db3afab5d8a547e067692bc6"
integrity sha512-WuXE+TaJLB8pdMuvIVY3LfT5UQqndR8+Js0xfhNpdXlsEx0Abwd1bzg4w4YWl2eoOmmLYrRpx6UJJ7a7/q6wZQ== integrity sha512-WuXE+TaJLB8pdMuvIVY3LfT5UQqndR8+Js0xfhNpdXlsEx0Abwd1bzg4w4YWl2eoOmmLYrRpx6UJJ7a7/q6wZQ==