Feature/ stylization 3 (#60)

* fix style picking css, preliminarily, ipadaptor ui

* fix the sizing of ipadapter

* IP Adapter workflow works

* react-easy-crop has much  better UI
This commit is contained in:
wil authored and GitHub committed 2024-10-29 11:06:52 -04:00
1 parent 0c57373500
commit 53e73a0ec8
12 files changed
+469 -84

No files matched your search

+6 -16
View File
@@ -19,7 +19,7 @@
"react-colorful": "^5.6.1",
"react-dom": "^18.3.1",
"react-drag-drop-files": "^2.3.10",
"react-easy-crop": "^5.0.8",
"react-image-crop": "^11.0.7",
"react-router-dom": "^6.26.1",
"tailwind-merge": "^2.5.2",
"uuid": "^10.0.0",
@@ -4102,11 +4102,6 @@
"node": ">=0.10.0"
}
},
"node_modules/normalize-wheel": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA=="
},
"node_modules/npm": {
"version": "10.8.3",
"resolved": "https://registry.npmjs.org/npm/-/npm-10.8.3.tgz",
@@ -7737,17 +7732,12 @@
"react-dom": "^18.0.0"
}
},
"node_modules/react-easy-crop": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.0.8.tgz",
"integrity": "sha512-KjulxXhR5iM7+ATN2sGCum/IyDxGw7xT0dFoGcqUP+ysaPU5Ka7gnrDa2tUHFHUoMNyPrVZ05QA+uvMgC5ym/g==",
"dependencies": {
"normalize-wheel": "^1.0.1",
"tslib": "^2.0.1"
},
"node_modules/react-image-crop": {
"version": "11.0.7",
"resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.0.7.tgz",
"integrity": "sha512-ZciKWHDYzmm366JDL18CbrVyjnjH0ojufGDmScfS4ZUqLHg4nm6ATY+K62C75W4ZRNt4Ii+tX0bSjNk9LQ2xzQ==",
"peerDependencies": {
"react": ">=16.4.0",
"react-dom": ">=16.4.0"
"react": ">=16.13.1"
}
},
"node_modules/react-is": {
+1 -1
View File
@@ -22,7 +22,7 @@
"react-colorful": "^5.6.1",
"react-dom": "^18.3.1",
"react-drag-drop-files": "^2.3.10",
"react-easy-crop": "^5.0.8",
"react-easy-crop": "^5.1.0",
"react-router-dom": "^6.26.1",
"tailwind-merge": "^2.5.2",
"uuid": "^10.0.0",
+10 -10
View File
@@ -9,20 +9,20 @@ import { PostHogProvider } from "posthog-js/react";
import "./global.css";
import { useRenderCounter } from "~/hooks/useRenderCounter";
function PostHogInit() {
const apiKey = "phc_jBFgac0mVALAFk3negnSfYCcHgvkT00yBLQDmCDYNBb";
posthog.init(apiKey, {
api_host: "https://us.i.posthog.com/",
ui_host: "https://us.i.posthog.com/",
});
}
// function PostHogInit() {
// const apiKey = "phc_jBFgac0mVALAFk3negnSfYCcHgvkT00yBLQDmCDYNBb";
// posthog.init(apiKey, {
// api_host: "https://us.i.posthog.com/",
// ui_host: "https://us.i.posthog.com/",
// });
// }
const App = () => {
const useStrictMode = false;
useRenderCounter("App");
useEffect(() => {
PostHogInit();
}, []);
// useEffect(() => {
// PostHogInit();
// }, []);
if (useStrictMode) {
return (
<StrictMode>
@@ -1,10 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react";
import {
faWandSparkles,
faChevronLeft,
} from "@fortawesome/pro-solid-svg-icons";
import { faWandSparkles } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "~/components/ui";
import {
@@ -115,35 +112,25 @@ export const DialogAiStylize = ({
)}
style={{ height: "calc(100vh - 200px)" }}
>
<DialogTitle className="font-bold">Use AI to Stylize</DialogTitle>
{panelState === SubPanelNames.BASIC && (
<>
<DialogTitle className="font-bold">Use AI to Stylize</DialogTitle>
<SubPanelBasic
selectedArtStyle={selectedArtStyle}
positivePrompt={positivePrompt}
negativePrompt={negativePrompt}
onSelectedArtStyle={onSelectedArtStyle}
onChangeNegativePrompt={onChangeNegativePrompt}
onChangePositivePrompt={onChangePositivePrompt}
onChangePanel={onChangePanel}
/>
</>
<SubPanelBasic
selectedArtStyle={selectedArtStyle}
positivePrompt={positivePrompt}
negativePrompt={negativePrompt}
onSelectedArtStyle={onSelectedArtStyle}
onChangeNegativePrompt={onChangeNegativePrompt}
onChangePositivePrompt={onChangePositivePrompt}
onChangePanel={onChangePanel}
/>
)}
{panelState === SubPanelNames.ADVANCED && (
<>
<Button
onClick={() => onChangePanel(SubPanelNames.BASIC)}
variant="tertiary"
className="my-1 w-fit"
icon={faChevronLeft}
>
Back to Basic Options
</Button>
<SubPanelAdvance
aiStylizeProps={aiStylizeProps}
onStylizeOptionsChanged={setStylizeOptions}
/>
</>
<SubPanelAdvance
aiStylizeProps={aiStylizeProps}
onStylizeOptionsChanged={setStylizeOptions}
onChangePanel={onChangePanel}
/>
)}
<div className="flex w-full justify-center gap-4">
<Button onClick={closeCallback} variant="secondary">
@@ -0,0 +1,227 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { FileUploader } from "react-drag-drop-files";
import Cropper, { Point, Area } from "react-easy-crop";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFileImage, faXmark } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "~/components/ui";
import getCroppedImg, {
base64ToFile,
uploadIPA,
fetchIPAUrl,
} from "./utilities";
type Crop = {
location: Point;
zoom: number;
cropArea?: Area;
croppedAreaPixels?: Area;
};
type State = {
ipaUrl?: string;
imageFileString?: string;
crop: Crop;
};
const initialCrop = {
location: { x: 0, y: 0 },
zoom: 1,
};
export const IPAdapter = ({
ipaToken,
onUploadedIPA,
}: {
ipaToken?: string;
onUploadedIPA: (newIpaToken: string) => void;
}) => {
const FILE_TYPES = ["JPG", "PNG", "JPEG"];
const [state, setState] = useState<State>({
crop: initialCrop,
});
const { imageFileString, ipaUrl, crop } = state;
const prevIpaToken = useRef<string | undefined>();
useEffect(() => {
const setIpaUrl = async () => {
if (ipaToken && ipaToken !== prevIpaToken.current) {
const ipaUrl = await fetchIPAUrl(ipaToken);
setState((curr) => ({ ...curr, ipaUrl }));
}
};
setIpaUrl();
}, [ipaToken]);
// this loads the file from drag-and-drop file uploader
// turn it into a string for the react-image-crop to use
const onFileChange = useCallback((file: File) => {
const reader = new FileReader();
reader.onloadend = () => {
setState((curr) => {
return {
...curr,
imageFileString: reader.result as string,
};
});
};
reader.readAsDataURL(file);
}, []);
// handler for react-image-crop's completion
const onCropComplete = (croppedArea: Area, croppedAreaPixels: Area) => {
console.log("on CROP COMPLETE", croppedArea, croppedAreaPixels);
setState((curr) => ({
...curr,
crop: {
...curr.crop,
cropArea: croppedArea,
croppedAreaPixels: croppedAreaPixels,
},
}));
};
const onSaveCrop = async () => {
if (!crop.croppedAreaPixels || !imageFileString) {
console.error("Error: do not have the assets for saving a cropped image");
setState({ crop: initialCrop }); // complete reset
return;
}
const croppedImageUrl = await getCroppedImg(
imageFileString,
crop.croppedAreaPixels,
);
if (!croppedImageUrl) {
console.error("Error: failed to crop image");
setState({ crop: initialCrop }); // complete reset
return;
}
// converts base64 cropped image into a file for upload
const croppedImageFile = base64ToFile(croppedImageUrl, "cropped-image.jpg");
const ipaToken = await uploadIPA(croppedImageFile);
if (ipaToken) {
onUploadedIPA(ipaToken);
}
};
const onDeleteImage = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
setState({ crop: initialCrop });
};
const DragAndDropZone = () => {
return (
<div className="border-ui-controls-button/50 bg-brand-secondary hover:bg-ui-controls-button/40 flex cursor-pointer items-center gap-3.5 rounded-lg border-2 border-dashed p-3 transition-all duration-150">
<FontAwesomeIcon icon={faFileImage} className="text-3xl" />
<div className="flex flex-col gap-0 text-sm">
<p className="font-medium">
<u>Upload an image</u> or drop it here
</p>
<p className="flex items-center gap-2 text-sm font-normal opacity-50">
{FILE_TYPES.join(", ").toString()} supported
</p>
</div>
</div>
);
};
return (
<div className="flex h-full flex-col gap-4">
<div>
<h4>IP Adapter</h4>
Additional Style Reference Image{" "}
<span className="text-xs font-normal">(Optional)</span>
</div>
{!imageFileString &&
!ipaUrl && ( //case of no file
<FileUploader
handleChange={onFileChange}
name="file"
types={FILE_TYPES}
>
<DragAndDropZone />
</FileUploader>
)}
{imageFileString &&
!ipaUrl && ( // file staged for cropping
<div
title="Crop Reference Image"
className="flex grow flex-col gap-4"
>
<div className="relative w-full grow">
<div className="absolute h-full w-full overflow-scroll rounded bg-black/25">
<Cropper
image={imageFileString}
crop={crop.location}
zoom={crop.zoom}
aspect={1}
onCropChange={(newCropLocation) => {
setState((curr) => ({
...curr,
crop: {
...curr.crop,
location: newCropLocation,
},
}));
}}
onCropComplete={onCropComplete}
onZoomChange={(newZoom) => {
setState((curr) => ({
...curr,
zoom: newZoom,
}));
}}
/>
</div>
</div>
<div className="flex justify-center gap-2">
<Button
onClick={() => {
setState((curr) => ({
...curr,
crop: initialCrop,
imageFileString: undefined,
}));
}}
variant="secondary"
>
Cancel
</Button>
<Button onClick={onSaveCrop} variant="primary">
Confirm
</Button>
</div>
</div>
)}
{ipaUrl && ( //case of having ipaFileUrl
<div className="relative">
<FileUploader
handleChange={onFileChange}
name="file"
types={FILE_TYPES}
>
<div className="flex cursor-pointer items-center justify-center overflow-hidden rounded-lg border-2 border-white/10 bg-black/25">
<img
src={ipaUrl}
alt="IPAdapter"
className="object-contain"
crossOrigin="anonymous"
/>
</div>
</FileUploader>
<Button
className="text-md absolute right-2 top-2 z-10 h-6 w-6 rounded-full font-medium hover:bg-primary"
onClick={onDeleteImage}
icon={faXmark}
variant="action"
/>
</div>
)}
</div>
);
};
@@ -1,13 +1,19 @@
import { faChevronLeft } from "@fortawesome/pro-solid-svg-icons";
import { StyleOptionSwitches } from "./StyleOptionSwitches";
import { AIStylizeProps } from "../utilities";
import { StyleStrengthSlider } from "./StyleStrengthSlider";
import { IPAdapter } from "./IPAdapter";
import { Button } from "~/components/ui";
import { SubPanelNames } from "../enums";
export const SubPanelAdvance = ({
aiStylizeProps,
onStylizeOptionsChanged,
onChangePanel,
}: {
aiStylizeProps: AIStylizeProps;
onStylizeOptionsChanged: (newOptions: Partial<AIStylizeProps>) => void;
onChangePanel: (newP: SubPanelNames) => void;
}) => {
const {
cinematic,
@@ -19,11 +25,18 @@ export const SubPanelAdvance = ({
} = aiStylizeProps;
return (
<div className="flex w-full grow gap-2">
<div className="flex w-2/3 flex-col">
<h6>IP Adapter</h6>
<div className="flex w-full grow gap-4">
<div className="w-2/3">
<IPAdapter
ipaToken={aiStylizeProps.globalIpaMediaToken}
onUploadedIPA={(newToken) =>
onStylizeOptionsChanged({
globalIpaMediaToken: newToken,
})
}
/>
</div>
<div className="flex w-1/3 flex-col gap-4">
<div className="flex w-1/3 flex-col justify-between gap-4">
<h4>Advanced Options</h4>
<StyleOptionSwitches
faceDetail={faceDetail}
@@ -38,6 +51,15 @@ export const SubPanelAdvance = ({
styleStrength={styleStrength}
onStylizeOptionsChanged={onStylizeOptionsChanged}
/>
<span className="grow" />
<Button
onClick={() => onChangePanel(SubPanelNames.BASIC)}
variant="tertiary"
className="w-fit self-end"
icon={faChevronLeft}
>
Back to Basic Options
</Button>
</div>
</div>
);
@@ -0,0 +1,158 @@
import { v4 as uuidv4 } from "uuid";
import { MediaFilesApi, MediaUploadApi } from "~/Classes/ApiManager";
import { Area } from "react-easy-crop";
export const base64ToFile = (base64: string, filename: string): File => {
const arr = base64.split(",");
const mime = arr[0].match(/:(.*?);/)?.[1];
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new File([u8arr], filename, { type: mime });
};
export const uploadIPA = async (file: File) => {
const mediaUploadApi = new MediaUploadApi();
const response = await mediaUploadApi.UploadImage({
blob: file,
fileName: file.name,
uuid: uuidv4(),
});
if (!response.success || !response.data) {
console.error("Error IPA: Fail in uploading image");
return;
}
return response.data;
};
export const fetchIPAUrl = async (ipaToken: string) => {
const mediaFilesApi = new MediaFilesApi();
try {
const response = await mediaFilesApi.GetMediaFileByToken({
mediaFileToken: ipaToken,
});
if (response.success && response.data) {
return response.data.media_links.cdn_url;
} else {
console.error("Failed to fetch media file:", response.errorMessage);
}
} catch (error) {
console.error("Error fetching media file:", error);
}
};
export const createImage = (url: string) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.addEventListener("load", () => resolve(image));
image.addEventListener("error", (error) => reject(error));
image.setAttribute("crossOrigin", "anonymous");
// needed to avoid cross-origin issues
image.src = url;
});
export function getRadianAngle(degreeValue: number) {
return (degreeValue * Math.PI) / 180;
}
/**
* Returns the new bounding area of a rotated rectangle.
*/
export function rotateSize(width: number, height: number, rotation: number) {
const rotRad = getRadianAngle(rotation);
return {
width:
Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height),
height:
Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height),
};
}
/**
* This function was adapted from the one in the ReadMe of https://github.com/DominicTobias/react-image-crop
*/
export default async function getCroppedImg(
imageSrc: string,
pixelCrop: Area,
rotation = 0,
flip = { horizontal: false, vertical: false },
) {
const image = await createImage(imageSrc);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
return null;
}
const rotRad = getRadianAngle(rotation);
// calculate bounding box of the rotated image
const { width: bBoxWidth, height: bBoxHeight } = rotateSize(
image.width,
image.height,
rotation,
);
// set canvas size to match the bounding box
canvas.width = bBoxWidth;
canvas.height = bBoxHeight;
// translate canvas context to a central location to allow rotating and flipping around the center
ctx.translate(bBoxWidth / 2, bBoxHeight / 2);
ctx.rotate(rotRad);
ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1);
ctx.translate(-image.width / 2, -image.height / 2);
// draw rotated image
ctx.drawImage(image, 0, 0);
const croppedCanvas = document.createElement("canvas");
const croppedCtx = croppedCanvas.getContext("2d");
if (!croppedCtx) {
return null;
}
// Set the size of the cropped canvas
croppedCanvas.width = pixelCrop.width;
croppedCanvas.height = pixelCrop.height;
// Draw the cropped image onto the new canvas
croppedCtx.drawImage(
canvas,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height,
);
// As Base64 string
// return croppedCanvas.toDataURL('image/jpeg');
// As a blob
return new Promise<string>((resolve, reject) => {
croppedCanvas.toBlob((blob) => {
if (blob) {
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
resolve(reader.result as string);
};
} else {
reject(new Error("Canvas is empty"));
}
}, "image/jpeg");
});
}
@@ -38,7 +38,7 @@ export const ArtStyleItem = ({
return (
<button
className={twMerge(
"relative aspect-video cursor-pointer overflow-hidden rounded-lg border-2 transition-colors",
"relative aspect-video w-full cursor-pointer overflow-hidden rounded-lg border-2 transition-colors",
transitionTimingStyles,
selected
? "border-primary"
@@ -47,12 +47,7 @@ export const ArtStyleItem = ({
)}
onClick={handleSelected}
>
<img
className="h-full w-full object-cover"
src={imageSrc}
{...imgProps}
alt={label}
/>
<img className="object-cover" src={imageSrc} {...imgProps} alt={label} />
<div className="absolute bottom-0 left-0 h-1/2 w-full bg-gradient-to-t from-ui-panel" />
<h4 className="absolute bottom-0 left-1 truncate text-start text-sm drop-shadow-md">
{label}
@@ -11,8 +11,8 @@ export const ArtStyleSelector = ({
}) => {
return (
<div className="flex h-0 flex-auto overflow-hidden">
<div className="overflow-y-auto">
<div className="grid grid-cols-3 gap-2">
<div className="w-full overflow-y-auto">
<div className="grid w-full grid-cols-3 gap-2">
{styleList.map((style) => (
<ArtStyleItem
key={style.type}
@@ -1,9 +1,9 @@
// import { faChevronRight } from "@fortawesome/pro-solid-svg-icons";
// import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronRight } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ArtStyleSelector } from "./ArtStyleSelector";
import { Prompts } from "./Prompts";
// import { Button } from "~/components/ui";
import { Button } from "~/components/ui";
import { ArtStyleNames, SubPanelNames } from "../enums";
export const SubPanelBasic = ({
@@ -13,7 +13,7 @@ export const SubPanelBasic = ({
onSelectedArtStyle,
onChangePositivePrompt,
onChangeNegativePrompt,
// onChangePanel,
onChangePanel,
}: {
selectedArtStyle: ArtStyleNames;
positivePrompt: string;
@@ -24,7 +24,7 @@ export const SubPanelBasic = ({
onChangePanel: (newP: SubPanelNames) => void;
}) => {
return (
<div className="flex w-full grow gap-2">
<div className="flex w-full grow gap-4">
<div className="flex w-2/3 flex-col">
<h4>Pick a Style</h4>
<ArtStyleSelector
@@ -41,14 +41,14 @@ export const SubPanelBasic = ({
onChangeNegativePrompt={onChangeNegativePrompt}
/>
<span className="grow" />
{/* <Button
<Button
onClick={() => onChangePanel(SubPanelNames.ADVANCED)}
variant="tertiary"
className="w-fit self-end"
>
Advanced Options
<FontAwesomeIcon icon={faChevronRight} />
</Button> */}
</Button>
</div>
</div>
);
@@ -5,6 +5,7 @@ import {
} from "./data/randomTextList";
export type AIStylizeProps = {
globalIpaMediaToken?: string;
selectedArtStyle: ArtStyleNames;
positivePrompt: string;
negativePrompt: string;
+12 -7
View File
@@ -2221,7 +2221,7 @@ fs-minipass@^3.0.0, fs-minipass@^3.0.3:
fsevents@~2.3.2, fsevents@~2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
function-bind@^1.1.2:
@@ -3225,7 +3225,7 @@ normalize-range@^0.1.2:
normalize-wheel@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz"
resolved "https://registry.yarnpkg.com/normalize-wheel/-/normalize-wheel-1.0.1.tgz#aec886affdb045070d856447df62ecf86146ec45"
integrity sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==
npm-audit-report@^5.0.0:
@@ -3779,10 +3779,10 @@ react-drag-drop-files@^2.3.10:
prop-types "^15.7.2"
styled-components "^5.3.0"
react-easy-crop@^5.0.8:
version "5.0.8"
resolved "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.0.8.tgz"
integrity sha512-KjulxXhR5iM7+ATN2sGCum/IyDxGw7xT0dFoGcqUP+ysaPU5Ka7gnrDa2tUHFHUoMNyPrVZ05QA+uvMgC5ym/g==
react-easy-crop@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/react-easy-crop/-/react-easy-crop-5.1.0.tgz#79897cbd8fbc93f64817276a02de525582a6334b"
integrity sha512-UsYeF/N7zoqtfOSD+2xSt1nRaoBYCI2YLkzmq+hi+aVepS4/bAMhbrLwJtDAP60jsVzWRiQCX7JG+ZtfWcHsiw==
dependencies:
normalize-wheel "^1.0.1"
tslib "^2.0.1"
@@ -4394,7 +4394,12 @@ tsconfck@^3.0.3:
resolved "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.1.tgz"
integrity sha512-00eoI6WY57SvZEVjm13stEVE90VkEdJAFGgpFLTsZbJyW/LwFQ7uQxJHWpZ2hzSWgCPKc9AnBnNP+0X7o3hAmQ==
tslib@^2.0.1, tslib@^2.4.0:
tslib@^2.0.1:
version "2.8.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.0.tgz#d124c86c3c05a40a91e6fdea4021bd31d377971b"
integrity sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==
tslib@^2.4.0:
version "2.7.0"
resolved "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz"
integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==