1
0
Fork 0

feat(gui): persist image control state (fixes #11)

This commit is contained in:
Sean Sube 2023-01-10 20:43:14 -06:00
parent c8b2abc110
commit 07fa81a66b
11 changed files with 293 additions and 143 deletions

View File

@ -20,7 +20,8 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-query": "^3.39.2",
"tslib": "^2.4.1"
"tslib": "^2.4.1",
"zustand": "^4.3.1"
},
"devDependencies": {
"@mochajs/multi-reporter": "^1.1.0",

View File

@ -72,11 +72,14 @@ export interface ApiClient {
export const STATUS_SUCCESS = 200;
export function paramsFromConfig(defaults: ConfigParams): BaseImgParams {
export function paramsFromConfig(defaults: ConfigParams): Required<BaseImgParams> {
return {
cfg: defaults.cfg.default,
model: defaults.model.default,
negativePrompt: defaults.negativePrompt.default,
platform: defaults.platform.default,
prompt: defaults.prompt.default,
scheduler: defaults.scheduler.default,
steps: defaults.steps.default,
seed: defaults.seed.default,
};

View File

@ -1,11 +1,17 @@
import { doesExist } from '@apextoaster/js-utils';
import { doesExist, mustDefault, mustExist } from '@apextoaster/js-utils';
import { Casino } from '@mui/icons-material';
import { Button, Stack, TextField } from '@mui/material';
import * as React from 'react';
import { useQuery } from 'react-query';
import { BaseImgParams } from '../api/client.js';
import { ConfigParams } from '../config.js';
import { ConfigParams, STALE_TIME } from '../config.js';
import { ClientContext } from '../main.js';
import { SCHEDULER_LABELS } from '../strings.js';
import { NumericField } from './NumericField.js';
import { QueryList } from './QueryList.js';
const { useContext } = React;
export interface ImageControlProps {
config: ConfigParams;
@ -14,10 +20,33 @@ export interface ImageControlProps {
onChange?: (params: BaseImgParams) => void;
}
/**
* doesn't need to use state, the parent component knows which params to pass
*/
export function ImageControl(props: ImageControlProps) {
const { config, params } = props;
const client = mustExist(useContext(ClientContext));
const schedulers = useQuery('schedulers', async () => client.schedulers(), {
staleTime: STALE_TIME,
});
return <Stack spacing={2}>
<QueryList
id='schedulers'
labels={SCHEDULER_LABELS}
name='Scheduler'
result={schedulers}
value={mustDefault(params.scheduler, '')}
onChange={(value) => {
if (doesExist(props.onChange)) {
props.onChange({
...params,
scheduler: value,
});
}
}}
/>
<Stack direction='row' spacing={4}>
<NumericField
decimal

View File

@ -1,22 +1,21 @@
import { mustExist } from '@apextoaster/js-utils';
import { Box, Button, Stack } from '@mui/material';
import * as React from 'react';
import { useMutation, useQuery } from 'react-query';
import { useMutation } from 'react-query';
import { useStore } from 'zustand';
import { ApiClient, BaseImgParams, paramsFromConfig } from '../api/client.js';
import { ConfigParams, IMAGE_FILTER, STALE_TIME } from '../config.js';
import { SCHEDULER_LABELS } from '../strings.js';
import { equalResponse } from '../api/client.js';
import { ConfigParams, IMAGE_FILTER } from '../config.js';
import { ClientContext, StateContext } from '../main.js';
import { ImageCard } from './ImageCard.js';
import { ImageControl } from './ImageControl.js';
import { ImageInput } from './ImageInput.js';
import { MutationHistory } from './MutationHistory.js';
import { NumericField } from './NumericField.js';
import { QueryList } from './QueryList.js';
const { useState } = React;
const { useContext, useState } = React;
export interface Img2ImgProps {
client: ApiClient;
config: ConfigParams;
model: string;
@ -24,46 +23,28 @@ export interface Img2ImgProps {
}
export function Img2Img(props: Img2ImgProps) {
const { client, config, model, platform } = props;
const { config, model, platform } = props;
async function uploadSource() {
return client.img2img({
...params,
...state.img2img,
model,
platform,
scheduler,
strength,
source: mustExist(source), // TODO: show an error if this doesn't exist
});
}
const client = mustExist(useContext(ClientContext));
const upload = useMutation(uploadSource);
const schedulers = useQuery('schedulers', async () => client.schedulers(), {
staleTime: STALE_TIME,
});
const state = useStore(mustExist(useContext(StateContext)));
const [source, setSource] = useState<File>();
const [strength, setStrength] = useState(config.strength.default);
const [params, setParams] = useState<BaseImgParams>(paramsFromConfig(config));
const [scheduler, setScheduler] = useState(config.scheduler.default);
return <Box>
<Stack spacing={2}>
<Stack direction='row' spacing={2}>
<QueryList
id='schedulers'
labels={SCHEDULER_LABELS}
name='Scheduler'
result={schedulers}
value={scheduler}
onChange={(value) => {
setScheduler(value);
}}
/>
</Stack>
<ImageInput filter={IMAGE_FILTER} label='Source' onChange={setSource} />
<ImageControl config={config} params={params} onChange={(newParams) => {
setParams(newParams);
<ImageControl config={config} params={state.img2img} onChange={(newParams) => {
state.setImg2Img(newParams);
}} />
<NumericField
decimal
@ -71,14 +52,16 @@ export function Img2Img(props: Img2ImgProps) {
min={config.strength.min}
max={config.strength.max}
step={config.strength.step}
value={strength}
value={state.img2img.strength}
onChange={(value) => {
setStrength(value);
state.setImg2Img({
strength: value,
});
}}
/>
<Button onClick={() => upload.mutate()}>Generate</Button>
<MutationHistory result={upload} limit={4} element={ImageCard}
isEqual={(a, b) => a.output === b.output}
isEqual={equalResponse}
/>
</Stack>
</Box>;

View File

@ -2,19 +2,19 @@ import { doesExist, mustExist } from '@apextoaster/js-utils';
import { FormatColorFill, Gradient } from '@mui/icons-material';
import { Box, Button, Stack } from '@mui/material';
import * as React from 'react';
import { useMutation, useQuery } from 'react-query';
import { useMutation } from 'react-query';
import { useStore } from 'zustand';
import { ApiClient, ApiResponse, BaseImgParams, equalResponse, paramsFromConfig } from '../api/client.js';
import { ConfigParams, DEFAULT_BRUSH, IMAGE_FILTER, STALE_TIME } from '../config.js';
import { SCHEDULER_LABELS } from '../strings.js';
import { ApiResponse, equalResponse } from '../api/client.js';
import { ConfigParams, DEFAULT_BRUSH, IMAGE_FILTER } from '../config.js';
import { ClientContext, StateContext } from '../main.js';
import { ImageCard } from './ImageCard.js';
import { ImageControl } from './ImageControl.js';
import { ImageInput } from './ImageInput.js';
import { MutationHistory } from './MutationHistory.js';
import { NumericField } from './NumericField.js';
import { QueryList } from './QueryList.js';
const { useEffect, useRef, useState } = React;
const { useContext, useEffect, useRef, useState } = React;
export const FULL_CIRCLE = 2 * Math.PI;
export const PIXEL_SIZE = 4;
@ -46,6 +46,10 @@ export function floodAbove(n: number): number {
}
}
export function floodGray(n: number): number {
return n;
}
export function grayToRGB(n: number): string {
return `rgb(${n.toFixed(0)},${n.toFixed(0)},${n.toFixed(0)})`;
}
@ -56,7 +60,6 @@ export interface Point {
}
export interface InpaintProps {
client: ApiClient;
config: ConfigParams;
model: string;
@ -64,17 +67,17 @@ export interface InpaintProps {
}
export function Inpaint(props: InpaintProps) {
const { client, config, model, platform } = props;
const { config, model, platform } = props;
const client = mustExist(useContext(ClientContext));
async function uploadSource() {
const canvas = mustExist(canvasRef.current);
return new Promise<ApiResponse>((res, _rej) => {
canvas.toBlob((blob) => {
res(client.inpaint({
...params,
...state.inpaint,
model,
platform,
scheduler,
mask: mustExist(blob),
source: mustExist(source),
}));
@ -84,13 +87,14 @@ export function Inpaint(props: InpaintProps) {
function drawSource(file: File) {
const image = new Image();
const src = URL.createObjectURL(file);
image.onload = () => {
const canvas = mustExist(canvasRef.current);
const ctx = mustExist(canvas.getContext('2d'));
ctx.drawImage(image, 0, 0);
URL.revokeObjectURL(src);
};
const src = URL.createObjectURL(file);
image.src = src;
}
@ -110,25 +114,6 @@ export function Inpaint(props: InpaintProps) {
}
}
function grayscaleMask() {
const canvas = mustExist(canvasRef.current);
const ctx = mustExist(canvas.getContext('2d'));
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = image.data;
for (let x = 0; x < canvas.width; ++x) {
for (let y = 0; y < canvas.height; ++y) {
const i = (y * canvas.width * PIXEL_SIZE) + (x * PIXEL_SIZE);
const hue = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / PIXEL_WEIGHT;
pixels[i] = hue;
pixels[i + 1] = hue;
pixels[i + 2] = hue;
}
}
ctx.putImageData(image, 0, 0);
}
function floodMask(flooder: (n: number) => number) {
const canvas = mustExist(canvasRef.current);
const ctx = mustExist(canvas.getContext('2d'));
@ -151,22 +136,19 @@ export function Inpaint(props: InpaintProps) {
}
const upload = useMutation(uploadSource);
const schedulers = useQuery('schedulers', async () => client.schedulers(), {
staleTime: STALE_TIME,
});
// eslint-disable-next-line no-null/no-null
const canvasRef = useRef<HTMLCanvasElement>(null);
const [clicks, setClicks] = useState<Array<Point>>([]);
const state = useStore(mustExist(useContext(StateContext)));
// painting state
const [clicks, setClicks] = useState<Array<Point>>([]);
const [painting, setPainting] = useState(false);
const [brushColor, setBrushColor] = useState(DEFAULT_BRUSH.color);
const [brushSize, setBrushSize] = useState(DEFAULT_BRUSH.size);
// image state
const [mask, setMask] = useState<File>();
const [source, setSource] = useState<File>();
const [params, setParams] = useState<BaseImgParams>(paramsFromConfig(config));
const [scheduler, setScheduler] = useState(config.scheduler.default);
useEffect(() => {
const canvas = mustExist(canvasRef.current);
@ -228,18 +210,6 @@ export function Inpaint(props: InpaintProps) {
return <Box>
<Stack spacing={2}>
<Stack direction='row' spacing={2}>
<QueryList
id='schedulers'
labels={SCHEDULER_LABELS}
name='Scheduler'
result={schedulers}
value={scheduler}
onChange={(value) => {
setScheduler(value);
}}
/>
</Stack>
<ImageInput filter={IMAGE_FILTER} label='Source' onChange={changeSource} />
<ImageInput filter={IMAGE_FILTER} label='Mask' onChange={changeMask} renderImage={renderCanvas} />
<Stack direction='row' spacing={4}>
@ -274,7 +244,7 @@ export function Inpaint(props: InpaintProps) {
<Button
variant='outlined'
startIcon={<Gradient />}
onClick={() => grayscaleMask()}>
onClick={() => floodMask(floodGray)}>
Grayscale
</Button>
<Button
@ -284,9 +254,13 @@ export function Inpaint(props: InpaintProps) {
Gray to white
</Button>
</Stack>
<ImageControl config={config} params={params} onChange={(newParams) => {
setParams(newParams);
}} />
<ImageControl
config={config}
params={state.inpaint}
onChange={(newParams) => {
state.setInpaint(newParams);
}}
/>
<Button onClick={() => upload.mutate()}>Generate</Button>
<MutationHistory result={upload} limit={4} element={ImageCard}
isEqual={equalResponse}

View File

@ -1,3 +1,4 @@
import { mustExist } from '@apextoaster/js-utils';
import { TabContext, TabList, TabPanel } from '@mui/lab';
import { Box, Container, Stack, Tab, Typography } from '@mui/material';
import * as React from 'react';
@ -5,13 +6,15 @@ import { useQuery } from 'react-query';
import { ApiClient } from '../api/client.js';
import { ConfigParams, STALE_TIME } from '../config.js';
import { ClientContext } from '../main.js';
import { MODEL_LABELS, PLATFORM_LABELS } from '../strings.js';
import { Img2Img } from './Img2Img.js';
import { Inpaint } from './Inpaint.js';
import { QueryList } from './QueryList.js';
import { Settings } from './Settings.js';
import { Txt2Img } from './Txt2Img.js';
const { useState } = React;
const { useContext, useState } = React;
export interface OnnxWebProps {
client: ApiClient;
@ -19,8 +22,9 @@ export interface OnnxWebProps {
}
export function OnnxWeb(props: OnnxWebProps) {
const { client, config } = props;
const { config } = props;
const client = mustExist(useContext(ClientContext));
const [tab, setTab] = useState('txt2img');
const [model, setModel] = useState(config.model.default);
const [platform, setPlatform] = useState(config.platform.default);
@ -76,18 +80,16 @@ export function OnnxWeb(props: OnnxWebProps) {
</TabList>
</Box>
<TabPanel value='txt2img'>
<Txt2Img client={client} config={config} model={model} platform={platform} />
<Txt2Img config={config} model={model} platform={platform} />
</TabPanel>
<TabPanel value='img2img'>
<Img2Img client={client} config={config} model={model} platform={platform} />
<Img2Img config={config} model={model} platform={platform} />
</TabPanel>
<TabPanel value='inpaint'>
<Inpaint client={client} config={config} model={model} platform={platform} />
<Inpaint config={config} model={model} platform={platform} />
</TabPanel>
<TabPanel value='settings'>
<Box>
settings for onnx-web
</Box>
<Settings config={config} />
</TabPanel>
</TabContext>
</Container>

View File

@ -0,0 +1,46 @@
import { mustExist } from '@apextoaster/js-utils';
import { Button, Stack, TextField } from '@mui/material';
import * as React from 'react';
import { useStore } from 'zustand';
import { ConfigParams } from '../config.js';
import { StateContext } from '../main.js';
const { useContext } = React;
export interface SettingsProps {
config: ConfigParams;
}
export function Settings(_props: SettingsProps) {
const state = useStore(mustExist(useContext(StateContext)));
return <Stack spacing={2}>
<Stack direction='row' spacing={2}>
<Button onClick={() => state.resetTxt2Img()}>Reset Txt2Img</Button>
<Button onClick={() => state.resetImg2Img()}>Reset Img2Img</Button>
<Button onClick={() => state.resetInpaint()}>Reset Inpaint</Button>
<Button disabled>Reset All</Button>
</Stack>
<TextField variant='outlined' label='Default Model' value={state.defaults.model} onChange={(event) => {
state.setDefaults({
model: event.target.value,
});
}} />
<TextField variant='outlined' label='Default Platform' value={state.defaults.platform} onChange={(event) => {
state.setDefaults({
platform: event.target.value,
});
}} />
<TextField variant='outlined' label='Default Prompt' value={state.defaults.prompt} onChange={(event) => {
state.setDefaults({
prompt: event.target.value,
});
}} />
<TextField variant='outlined' label='Default Scheduler' value={state.defaults.scheduler} onChange={(event) => {
state.setDefaults({
scheduler: event.target.value,
});
}} />
</Stack>;
}

View File

@ -1,20 +1,20 @@
import { mustExist } from '@apextoaster/js-utils';
import { Box, Button, Stack } from '@mui/material';
import * as React from 'react';
import { useMutation, useQuery } from 'react-query';
import { useMutation } from 'react-query';
import { useStore } from 'zustand';
import { ApiClient, BaseImgParams, paramsFromConfig } from '../api/client.js';
import { ConfigParams, STALE_TIME } from '../config.js';
import { SCHEDULER_LABELS } from '../strings.js';
import { BaseImgParams, equalResponse, paramsFromConfig } from '../api/client.js';
import { ConfigParams } from '../config.js';
import { ClientContext, StateContext } from '../main.js';
import { ImageCard } from './ImageCard.js';
import { ImageControl } from './ImageControl.js';
import { MutationHistory } from './MutationHistory.js';
import { NumericField } from './NumericField.js';
import { QueryList } from './QueryList.js';
const { useState } = React;
const { useContext, useState } = React;
export interface Txt2ImgProps {
client: ApiClient;
config: ConfigParams;
model: string;
@ -22,45 +22,24 @@ export interface Txt2ImgProps {
}
export function Txt2Img(props: Txt2ImgProps) {
const { client, config, model, platform } = props;
const { config, model, platform } = props;
async function generateImage() {
return client.txt2img({
...params,
...state.txt2img,
model,
platform,
scheduler,
height,
width,
});
}
const client = mustExist(useContext(ClientContext));
const generate = useMutation(generateImage);
const schedulers = useQuery('schedulers', async () => client.schedulers(), {
staleTime: STALE_TIME,
});
const [height, setHeight] = useState(config.height.default);
const [width, setWidth] = useState(config.width.default);
const [params, setParams] = useState<BaseImgParams>(paramsFromConfig(config));
const [scheduler, setScheduler] = useState(config.scheduler.default);
const state = useStore(mustExist(useContext(StateContext)));
return <Box>
<Stack spacing={2}>
<Stack direction='row' spacing={2}>
<QueryList
id='schedulers'
labels={SCHEDULER_LABELS}
name='Scheduler'
result={schedulers}
value={scheduler}
onChange={(value) => {
setScheduler(value);
}}
/>
</Stack>
<ImageControl config={config} params={params} onChange={(newParams) => {
setParams(newParams);
<ImageControl config={config} params={state.txt2img} onChange={(newParams) => {
state.setTxt2Img(newParams);
}} />
<Stack direction='row' spacing={4}>
<NumericField
@ -68,9 +47,11 @@ export function Txt2Img(props: Txt2ImgProps) {
min={config.width.min}
max={config.width.max}
step={config.width.step}
value={width}
value={state.txt2img.width}
onChange={(value) => {
setWidth(value);
state.setTxt2Img({
width: value,
});
}}
/>
<NumericField
@ -78,15 +59,20 @@ export function Txt2Img(props: Txt2ImgProps) {
min={config.height.min}
max={config.height.max}
step={config.height.step}
value={height}
value={state.txt2img.height}
onChange={(value) => {
setHeight(value);
state.setTxt2Img({
height: value,
});
}}
/>
</Stack>
<Button onClick={() => generate.mutate()}>Generate</Button>
<MutationHistory result={generate} limit={4} element={ImageCard}
isEqual={(a, b) => a.output === b.output}
<MutationHistory
element={ImageCard}
limit={4}
isEqual={equalResponse}
result={generate}
/>
</Stack>
</Box>;

View File

@ -20,6 +20,10 @@ export type ConfigRanges<T extends object> = {
[K in KeyFilter<T>]: T[K] extends number ? ConfigNumber : T[K] extends string ? ConfigString : never;
};
export type ConfigState<T extends object> = {
[K in KeyFilter<T>]: T[K] extends number ? number : T[K] extends string ? string : never;
};
export type ConfigParams = ConfigRanges<Required<Img2ImgParams & Txt2ImgParams>>;
export interface Config {

View File

@ -1,13 +1,33 @@
/* eslint-disable no-console */
import { mustExist } from '@apextoaster/js-utils';
import { Maybe, mustExist } from '@apextoaster/js-utils';
import { merge } from 'lodash';
import * as React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from 'react-query';
import { createStore, StoreApi } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { makeClient } from './api/client.js';
import { ApiClient, BaseImgParams, Img2ImgParams, InpaintParams, makeClient, paramsFromConfig, Txt2ImgParams } from './api/client.js';
import { OnnxWeb } from './components/OnnxWeb.js';
import { loadConfig } from './config.js';
import { ConfigState, loadConfig } from './config.js';
const { createContext } = React;
interface OnnxState {
defaults: Required<BaseImgParams>;
txt2img: ConfigState<Required<Txt2ImgParams>>;
img2img: ConfigState<Required<Img2ImgParams>>;
inpaint: ConfigState<Required<InpaintParams>>;
setDefaults(newParams: Partial<BaseImgParams>): void;
setTxt2Img(newParams: Partial<ConfigState<Required<Txt2ImgParams>>>): void;
setImg2Img(newParams: Partial<ConfigState<Required<Img2ImgParams>>>): void;
setInpaint(newParams: Partial<ConfigState<Required<InpaintParams>>>): void;
resetTxt2Img(): void;
resetImg2Img(): void;
resetInpaint(): void;
}
export async function main() {
const config = await loadConfig();
@ -15,11 +35,98 @@ export async function main() {
const params = await client.params();
merge(params, config.params);
const defaults = paramsFromConfig(params);
const state = createStore<OnnxState, [['zustand/persist', never]]>(persist((set) => ({
defaults,
txt2img: {
...defaults,
height: params.height.default,
width: params.width.default,
},
img2img: {
...defaults,
strength: params.strength.default,
},
inpaint: {
...defaults,
},
setDefaults(newParams) {
set((oldState) => ({
...oldState,
defaults: {
...oldState.defaults,
...newParams,
},
}));
},
setTxt2Img(newParams) {
set((oldState) => ({
...oldState,
txt2img: {
...oldState.txt2img,
...newParams,
},
}));
},
setImg2Img(newParams) {
set((oldState) => ({
...oldState,
img2img: {
...oldState.img2img,
...newParams,
},
}));
},
setInpaint(newParams) {
set((oldState) => ({
...oldState,
inpaint: {
...oldState.inpaint,
...newParams,
},
}));
},
resetTxt2Img() {
set((oldState) => ({
...oldState,
txt2img: {
...defaults,
height: params.height.default,
width: params.width.default,
},
}));
},
resetImg2Img() {
set((oldState) => ({
...oldState,
img2img: {
...defaults,
strength: params.strength.default,
},
}));
},
resetInpaint() {
set((oldState) => ({
...oldState,
inpaint: {
...defaults,
},
}));
},
}), {
name: 'onnx-web',
storage: createJSONStorage(() => localStorage),
}));
const query = new QueryClient();
const appElement = mustExist(document.getElementById('app'));
const app = ReactDOM.createRoot(appElement);
app.render(<QueryClientProvider client={query}>
<OnnxWeb client={client} config={params} />
<ClientContext.Provider value={client}>
<StateContext.Provider value={state}>
<OnnxWeb client={client} config={params} />
</StateContext.Provider>
</ClientContext.Provider>
</QueryClientProvider>);
}
@ -29,3 +136,6 @@ window.addEventListener('load', () => {
console.error('error in main', err);
});
}, false);
export const ClientContext = createContext<Maybe<ApiClient>>(undefined);
export const StateContext = createContext<Maybe<StoreApi<OnnxState>>>(undefined);

View File

@ -2799,6 +2799,11 @@ uri-js@^4.2.2:
dependencies:
punycode "^2.1.0"
use-sync-external-store@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
v8-to-istanbul@^9.0.0:
version "9.0.1"
resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.0.1.tgz#b6f994b0b5d4ef255e17a0d17dc444a9f5132fa4"
@ -2919,3 +2924,10 @@ yocto-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
zustand@^4.3.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.3.1.tgz#76c47ef713c43763953f7a9e518f89efd898e3bb"
integrity sha512-EVyo/eLlOTcJm/X5M00rwtbYFXwRVTaRteSvhtbTZUCQFJkNfIyHPiJ6Ke68MSWzcKHpPzvqNH4gC2ZS/sbNqw==
dependencies:
use-sync-external-store "1.2.0"