mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-01-16 12:38:01 -05:00
Compare commits
122 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ec7469acf | ||
|
|
e522de33f8 | ||
|
|
d591b50c25 | ||
|
|
b365aad6d8 | ||
|
|
65ad392361 | ||
|
|
56d75e1c77 | ||
|
|
df77a12efe | ||
|
|
faf662d12e | ||
|
|
44a7dfd486 | ||
|
|
bb15e5cf06 | ||
|
|
1a1c846be3 | ||
|
|
93c896a370 | ||
|
|
053d7c8c8e | ||
|
|
5296263954 | ||
|
|
a36b70c01c | ||
|
|
854a2a5a7a | ||
|
|
f9c64b0609 | ||
|
|
5889fa536a | ||
|
|
0e71ba892f | ||
|
|
d766a21223 | ||
|
|
5c8c54eab8 | ||
|
|
f296f4525c | ||
|
|
7c9ba4cb52 | ||
|
|
6784fd5b43 | ||
|
|
11d68cc646 | ||
|
|
ea8c877025 | ||
|
|
7a3c2332dd | ||
|
|
3835fd2f72 | ||
|
|
6f8746040c | ||
|
|
35e3940a09 | ||
|
|
415616d83f | ||
|
|
afb67efef9 | ||
|
|
1ed1fefa60 | ||
|
|
fa94a05c77 | ||
|
|
7a23d8266f | ||
|
|
a44de079dd | ||
|
|
c3c1a3edd8 | ||
|
|
ea26b5b147 | ||
|
|
4226b741b1 | ||
|
|
1424b7c254 | ||
|
|
933fb2294c | ||
|
|
5a181ee0fd | ||
|
|
3b0d59e459 | ||
|
|
fec296e41d | ||
|
|
ae4e38c6d0 | ||
|
|
a9f3f1a4b2 | ||
|
|
8a73df4fe1 | ||
|
|
ea2e1ea8f0 | ||
|
|
e8aa91931d | ||
|
|
8d22a314a6 | ||
|
|
57ce2b8aa7 | ||
|
|
6b810cb3fb | ||
|
|
4f3a5dcc43 | ||
|
|
c3ae14cf73 | ||
|
|
b9c44b92d5 | ||
|
|
5a68b4ddbc | ||
|
|
18a722839b | ||
|
|
7370cb9be6 | ||
|
|
cc4df52f82 | ||
|
|
1cb4ef05a4 | ||
|
|
7da141101c | ||
|
|
2571e199c5 | ||
|
|
79e93f905e | ||
|
|
f562e4f835 | ||
|
|
47e220aaf3 | ||
|
|
9365154bfe | ||
|
|
afc6911c96 | ||
|
|
afa1ee7ffd | ||
|
|
5a102f6b53 | ||
|
|
af345a33f3 | ||
|
|
038b110a82 | ||
|
|
f3cd49d46e | ||
|
|
ca7d7c9d93 | ||
|
|
1addeb4b59 | ||
|
|
6ea4884b0c | ||
|
|
aed9b1013e | ||
|
|
6962536b4a | ||
|
|
7e59d040aa | ||
|
|
e7c67da2c2 | ||
|
|
c44571bc36 | ||
|
|
ca257650d4 | ||
|
|
6a9962d2bb | ||
|
|
9492569a2c | ||
|
|
61e711620d | ||
|
|
3cf82505bb | ||
|
|
53bcbc58f5 | ||
|
|
42f3990f7a | ||
|
|
456205da17 | ||
|
|
ca0684700e | ||
|
|
6a702821ef | ||
|
|
682d271f6f | ||
|
|
e872c253b1 | ||
|
|
28633c9983 | ||
|
|
70ac58e64a | ||
|
|
e653837236 | ||
|
|
2bbfcc2f13 | ||
|
|
d6e0e439c5 | ||
|
|
26aab60f81 | ||
|
|
7bea2fa11f | ||
|
|
1cdd4b5980 | ||
|
|
89ceecc870 | ||
|
|
687cccdb99 | ||
|
|
c84f8465b8 | ||
|
|
4b5c481b7a | ||
|
|
2caa1b166d | ||
|
|
1b6ebede7b | ||
|
|
017d38eee2 | ||
|
|
78eb6b0338 | ||
|
|
3e8e0f6ddf | ||
|
|
8213f62d3b | ||
|
|
233740a40e | ||
|
|
8c5fcfd0fd | ||
|
|
6d7b231196 | ||
|
|
31ca314b02 | ||
|
|
0db304f1ee | ||
|
|
a3cb3e03f4 | ||
|
|
641a6cfdb7 | ||
|
|
f27471cea7 | ||
|
|
47508b8d6c | ||
|
|
28e0242907 | ||
|
|
96523ca01f | ||
|
|
c10a6fdab1 |
12
.github/workflows/typegen-checks.yml
vendored
12
.github/workflows/typegen-checks.yml
vendored
@@ -39,6 +39,18 @@ jobs:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Free up more disk space on the runner
|
||||
# https://github.com/actions/runner-images/issues/2840#issuecomment-1284059930
|
||||
run: |
|
||||
echo "----- Free space before cleanup"
|
||||
df -h
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
||||
sudo swapoff /mnt/swapfile
|
||||
sudo rm -rf /mnt/swapfile
|
||||
echo "----- Free space after cleanup"
|
||||
df -h
|
||||
|
||||
- name: check for changed files
|
||||
if: ${{ inputs.always_run != true }}
|
||||
id: changed-files
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
## GPU_DRIVER can be set to either `cuda` or `rocm` to enable GPU support in the container accordingly.
|
||||
# GPU_DRIVER=cuda #| rocm
|
||||
|
||||
## If you are using ROCM, you will need to ensure that the render group within the container and the host system use the same group ID.
|
||||
## To obtain the group ID of the render group on the host system, run `getent group render` and grab the number.
|
||||
# RENDER_GROUP_ID=
|
||||
|
||||
## CONTAINER_UID can be set to the UID of the user on the host system that should own the files in the container.
|
||||
## It is usually not necessary to change this. Use `id -u` on the host system to find the UID.
|
||||
# CONTAINER_UID=1000
|
||||
|
||||
@@ -43,7 +43,6 @@ ENV \
|
||||
UV_MANAGED_PYTHON=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PROJECT_ENVIRONMENT=/opt/venv \
|
||||
UV_INDEX="https://download.pytorch.org/whl/cu124" \
|
||||
INVOKEAI_ROOT=/invokeai \
|
||||
INVOKEAI_HOST=0.0.0.0 \
|
||||
INVOKEAI_PORT=9090 \
|
||||
@@ -74,19 +73,17 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
# this is just to get the package manager to recognize that the project exists, without making changes to the docker layer
|
||||
--mount=type=bind,source=invokeai/version,target=invokeai/version \
|
||||
if [ "$TARGETPLATFORM" = "linux/arm64" ] || [ "$GPU_DRIVER" = "cpu" ]; then UV_INDEX="https://download.pytorch.org/whl/cpu"; \
|
||||
elif [ "$GPU_DRIVER" = "rocm" ]; then UV_INDEX="https://download.pytorch.org/whl/rocm6.2"; \
|
||||
fi && \
|
||||
uv sync --frozen
|
||||
|
||||
# build patchmatch
|
||||
RUN cd /usr/lib/$(uname -p)-linux-gnu/pkgconfig/ && ln -sf opencv4.pc opencv.pc
|
||||
RUN python -c "from patchmatch import patch_match"
|
||||
ulimit -n 30000 && \
|
||||
uv sync --extra $GPU_DRIVER --frozen
|
||||
|
||||
# Link amdgpu.ids for ROCm builds
|
||||
# contributed by https://github.com/Rubonnek
|
||||
RUN mkdir -p "/opt/amdgpu/share/libdrm" &&\
|
||||
ln -s "/usr/share/libdrm/amdgpu.ids" "/opt/amdgpu/share/libdrm/amdgpu.ids"
|
||||
ln -s "/usr/share/libdrm/amdgpu.ids" "/opt/amdgpu/share/libdrm/amdgpu.ids" && groupadd render
|
||||
|
||||
# build patchmatch
|
||||
RUN cd /usr/lib/$(uname -p)-linux-gnu/pkgconfig/ && ln -sf opencv4.pc opencv.pc
|
||||
RUN python -c "from patchmatch import patch_match"
|
||||
|
||||
RUN mkdir -p ${INVOKEAI_ROOT} && chown -R ${CONTAINER_UID}:${CONTAINER_GID} ${INVOKEAI_ROOT}
|
||||
|
||||
@@ -105,8 +102,6 @@ COPY invokeai ${INVOKEAI_SRC}/invokeai
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
if [ "$TARGETPLATFORM" = "linux/arm64" ] || [ "$GPU_DRIVER" = "cpu" ]; then UV_INDEX="https://download.pytorch.org/whl/cpu"; \
|
||||
elif [ "$GPU_DRIVER" = "rocm" ]; then UV_INDEX="https://download.pytorch.org/whl/rocm6.2"; \
|
||||
fi && \
|
||||
uv pip install -e .
|
||||
ulimit -n 30000 && \
|
||||
uv pip install -e .[$GPU_DRIVER]
|
||||
|
||||
|
||||
136
docker/Dockerfile-rocm-full
Normal file
136
docker/Dockerfile-rocm-full
Normal file
@@ -0,0 +1,136 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
|
||||
#### Web UI ------------------------------------
|
||||
|
||||
FROM docker.io/node:22-slim AS web-builder
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack use pnpm@8.x
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /build
|
||||
COPY invokeai/frontend/web/ ./
|
||||
RUN --mount=type=cache,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
RUN npx vite build
|
||||
|
||||
## Backend ---------------------------------------
|
||||
|
||||
FROM library/ubuntu:24.04
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
|
||||
RUN --mount=type=cache,target=/var/cache/apt \
|
||||
--mount=type=cache,target=/var/lib/apt \
|
||||
apt update && apt install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
git \
|
||||
gosu \
|
||||
libglib2.0-0 \
|
||||
libgl1 \
|
||||
libglx-mesa0 \
|
||||
build-essential \
|
||||
libopencv-dev \
|
||||
libstdc++-10-dev \
|
||||
wget
|
||||
|
||||
ENV \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
VIRTUAL_ENV=/opt/venv \
|
||||
INVOKEAI_SRC=/opt/invokeai \
|
||||
PYTHON_VERSION=3.12 \
|
||||
UV_PYTHON=3.12 \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
UV_MANAGED_PYTHON=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PROJECT_ENVIRONMENT=/opt/venv \
|
||||
INVOKEAI_ROOT=/invokeai \
|
||||
INVOKEAI_HOST=0.0.0.0 \
|
||||
INVOKEAI_PORT=9090 \
|
||||
PATH="/opt/venv/bin:$PATH" \
|
||||
CONTAINER_UID=${CONTAINER_UID:-1000} \
|
||||
CONTAINER_GID=${CONTAINER_GID:-1000}
|
||||
|
||||
ARG GPU_DRIVER=cuda
|
||||
|
||||
# Install `uv` for package management
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.6.9 /uv /uvx /bin/
|
||||
|
||||
# Install python & allow non-root user to use it by traversing the /root dir without read permissions
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv python install ${PYTHON_VERSION} && \
|
||||
# chmod --recursive a+rX /root/.local/share/uv/python
|
||||
chmod 711 /root
|
||||
|
||||
WORKDIR ${INVOKEAI_SRC}
|
||||
|
||||
# Install project's dependencies as a separate layer so they aren't rebuilt every commit.
|
||||
# bind-mount instead of copy to defer adding sources to the image until next layer.
|
||||
#
|
||||
# NOTE: there are no pytorch builds for arm64 + cuda, only cpu
|
||||
# x86_64/CUDA is the default
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
# this is just to get the package manager to recognize that the project exists, without making changes to the docker layer
|
||||
--mount=type=bind,source=invokeai/version,target=invokeai/version \
|
||||
ulimit -n 30000 && \
|
||||
uv sync --extra $GPU_DRIVER --frozen
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apt \
|
||||
--mount=type=cache,target=/var/lib/apt \
|
||||
if [ "$GPU_DRIVER" = "rocm" ]; then \
|
||||
wget -O /tmp/amdgpu-install.deb \
|
||||
https://repo.radeon.com/amdgpu-install/6.3.4/ubuntu/noble/amdgpu-install_6.3.60304-1_all.deb && \
|
||||
apt install -y /tmp/amdgpu-install.deb && \
|
||||
apt update && \
|
||||
amdgpu-install --usecase=rocm -y && \
|
||||
apt-get autoclean && \
|
||||
apt clean && \
|
||||
rm -rf /tmp/* /var/tmp/* && \
|
||||
usermod -a -G render ubuntu && \
|
||||
usermod -a -G video ubuntu && \
|
||||
echo "\\n/opt/rocm/lib\\n/opt/rocm/lib64" >> /etc/ld.so.conf.d/rocm.conf && \
|
||||
ldconfig && \
|
||||
update-alternatives --auto rocm; \
|
||||
fi
|
||||
|
||||
## Heathen711: Leaving this for review input, will remove before merge
|
||||
# RUN --mount=type=cache,target=/var/cache/apt \
|
||||
# --mount=type=cache,target=/var/lib/apt \
|
||||
# if [ "$GPU_DRIVER" = "rocm" ]; then \
|
||||
# groupadd render && \
|
||||
# usermod -a -G render ubuntu && \
|
||||
# usermod -a -G video ubuntu; \
|
||||
# fi
|
||||
|
||||
## Link amdgpu.ids for ROCm builds
|
||||
## contributed by https://github.com/Rubonnek
|
||||
# RUN mkdir -p "/opt/amdgpu/share/libdrm" &&\
|
||||
# ln -s "/usr/share/libdrm/amdgpu.ids" "/opt/amdgpu/share/libdrm/amdgpu.ids"
|
||||
|
||||
# build patchmatch
|
||||
RUN cd /usr/lib/$(uname -p)-linux-gnu/pkgconfig/ && ln -sf opencv4.pc opencv.pc
|
||||
RUN python -c "from patchmatch import patch_match"
|
||||
|
||||
RUN mkdir -p ${INVOKEAI_ROOT} && chown -R ${CONTAINER_UID}:${CONTAINER_GID} ${INVOKEAI_ROOT}
|
||||
|
||||
COPY docker/docker-entrypoint.sh ./
|
||||
ENTRYPOINT ["/opt/invokeai/docker-entrypoint.sh"]
|
||||
CMD ["invokeai-web"]
|
||||
|
||||
# --link requires buldkit w/ dockerfile syntax 1.4, does not work with podman
|
||||
COPY --link --from=web-builder /build/dist ${INVOKEAI_SRC}/invokeai/frontend/web/dist
|
||||
|
||||
# add sources last to minimize image changes on code changes
|
||||
COPY invokeai ${INVOKEAI_SRC}/invokeai
|
||||
|
||||
# this should not increase image size because we've already installed dependencies
|
||||
# in a previous layer
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
ulimit -n 30000 && \
|
||||
uv pip install -e .[$GPU_DRIVER]
|
||||
|
||||
@@ -47,8 +47,9 @@ services:
|
||||
|
||||
invokeai-rocm:
|
||||
<<: *invokeai
|
||||
devices:
|
||||
- /dev/kfd:/dev/kfd
|
||||
- /dev/dri:/dev/dri
|
||||
environment:
|
||||
- AMD_VISIBLE_DEVICES=all
|
||||
- RENDER_GROUP_ID=${RENDER_GROUP_ID}
|
||||
runtime: amd
|
||||
profiles:
|
||||
- rocm
|
||||
|
||||
@@ -21,6 +21,17 @@ _=$(id ${USER} 2>&1) || useradd -u ${USER_ID} ${USER}
|
||||
# ensure the UID is correct
|
||||
usermod -u ${USER_ID} ${USER} 1>/dev/null
|
||||
|
||||
## ROCM specific configuration
|
||||
# render group within the container must match the host render group
|
||||
# otherwise the container will not be able to access the host GPU.
|
||||
if [[ -v "RENDER_GROUP_ID" ]] && [[ ! -z "${RENDER_GROUP_ID}" ]]; then
|
||||
# ensure the render group exists
|
||||
groupmod -g ${RENDER_GROUP_ID} render
|
||||
usermod -a -G render ${USER}
|
||||
usermod -a -G video ${USER}
|
||||
fi
|
||||
|
||||
|
||||
### Set the $PUBLIC_KEY env var to enable SSH access.
|
||||
# We do not install openssh-server in the image by default to avoid bloat.
|
||||
# but it is useful to have the full SSH server e.g. on Runpod.
|
||||
|
||||
@@ -13,7 +13,7 @@ run() {
|
||||
|
||||
# parse .env file for build args
|
||||
build_args=$(awk '$1 ~ /=[^$]/ && $0 !~ /^#/ {print "--build-arg " $0 " "}' .env) &&
|
||||
profile="$(awk -F '=' '/GPU_DRIVER/ {print $2}' .env)"
|
||||
profile="$(awk -F '=' '/GPU_DRIVER=/ {print $2}' .env)"
|
||||
|
||||
# default to 'cuda' profile
|
||||
[[ -z "$profile" ]] && profile="cuda"
|
||||
@@ -30,7 +30,7 @@ run() {
|
||||
|
||||
printf "%s\n" "starting service $service_name"
|
||||
docker compose --profile "$profile" up -d "$service_name"
|
||||
docker compose logs -f
|
||||
docker compose --profile "$profile" logs -f
|
||||
}
|
||||
|
||||
run
|
||||
|
||||
@@ -69,34 +69,34 @@ The following commands vary depending on the version of Invoke being installed a
|
||||
- If you have an Nvidia 20xx series GPU or older, use `invokeai[xformers]`.
|
||||
- If you have an Nvidia 30xx series GPU or newer, or do not have an Nvidia GPU, use `invokeai`.
|
||||
|
||||
7. Determine the `PyPI` index URL to use for installation, if any. This is necessary to get the right version of torch installed.
|
||||
7. Determine the torch backend to use for installation, if any. This is necessary to get the right version of torch installed. This is acheived by using [UV's built in torch support.](https://docs.astral.sh/uv/guides/integration/pytorch/#automatic-backend-selection)
|
||||
|
||||
=== "Invoke v5.12 and later"
|
||||
|
||||
- If you are on Windows or Linux with an Nvidia GPU, use `https://download.pytorch.org/whl/cu128`.
|
||||
- If you are on Linux with no GPU, use `https://download.pytorch.org/whl/cpu`.
|
||||
- If you are on Linux with an AMD GPU, use `https://download.pytorch.org/whl/rocm6.2.4`.
|
||||
- **In all other cases, do not use an index.**
|
||||
- If you are on Windows or Linux with an Nvidia GPU, use `--torch-backend=cu128`.
|
||||
- If you are on Linux with no GPU, use `--torch-backend=cpu`.
|
||||
- If you are on Linux with an AMD GPU, use `--torch-backend=rocm6.3`.
|
||||
- **In all other cases, do not use a torch backend.**
|
||||
|
||||
=== "Invoke v5.10.0 to v5.11.0"
|
||||
|
||||
- If you are on Windows or Linux with an Nvidia GPU, use `https://download.pytorch.org/whl/cu126`.
|
||||
- If you are on Linux with no GPU, use `https://download.pytorch.org/whl/cpu`.
|
||||
- If you are on Linux with an AMD GPU, use `https://download.pytorch.org/whl/rocm6.2.4`.
|
||||
- If you are on Windows or Linux with an Nvidia GPU, use `--torch-backend=cu126`.
|
||||
- If you are on Linux with no GPU, use `--torch-backend=cpu`.
|
||||
- If you are on Linux with an AMD GPU, use `--torch-backend=rocm6.2.4`.
|
||||
- **In all other cases, do not use an index.**
|
||||
|
||||
=== "Invoke v5.0.0 to v5.9.1"
|
||||
|
||||
- If you are on Windows with an Nvidia GPU, use `https://download.pytorch.org/whl/cu124`.
|
||||
- If you are on Linux with no GPU, use `https://download.pytorch.org/whl/cpu`.
|
||||
- If you are on Linux with an AMD GPU, use `https://download.pytorch.org/whl/rocm6.1`.
|
||||
- If you are on Windows with an Nvidia GPU, use `--torch-backend=cu124`.
|
||||
- If you are on Linux with no GPU, use `--torch-backend=cpu`.
|
||||
- If you are on Linux with an AMD GPU, use `--torch-backend=rocm6.1`.
|
||||
- **In all other cases, do not use an index.**
|
||||
|
||||
=== "Invoke v4"
|
||||
|
||||
- If you are on Windows with an Nvidia GPU, use `https://download.pytorch.org/whl/cu124`.
|
||||
- If you are on Linux with no GPU, use `https://download.pytorch.org/whl/cpu`.
|
||||
- If you are on Linux with an AMD GPU, use `https://download.pytorch.org/whl/rocm5.2`.
|
||||
- If you are on Windows with an Nvidia GPU, use `--torch-backend=cu124`.
|
||||
- If you are on Linux with no GPU, use `--torch-backend=cpu`.
|
||||
- If you are on Linux with an AMD GPU, use `--torch-backend=rocm5.2`.
|
||||
- **In all other cases, do not use an index.**
|
||||
|
||||
8. Install the `invokeai` package. Substitute the package specifier and version.
|
||||
@@ -105,10 +105,10 @@ The following commands vary depending on the version of Invoke being installed a
|
||||
uv pip install <PACKAGE_SPECIFIER>==<VERSION> --python 3.12 --python-preference only-managed --force-reinstall
|
||||
```
|
||||
|
||||
If you determined you needed to use a `PyPI` index URL in the previous step, you'll need to add `--index=<INDEX_URL>` like this:
|
||||
If you determined you needed to use a torch backend in the previous step, you'll need to set the backend like this:
|
||||
|
||||
```sh
|
||||
uv pip install <PACKAGE_SPECIFIER>==<VERSION> --python 3.12 --python-preference only-managed --index=<INDEX_URL> --force-reinstall
|
||||
uv pip install <PACKAGE_SPECIFIER>==<VERSION> --python 3.12 --python-preference only-managed --torch-backend=<VERSION> --force-reinstall
|
||||
```
|
||||
|
||||
9. Deactivate and reactivate your venv so that the invokeai-specific commands become available in the environment:
|
||||
|
||||
@@ -10,6 +10,7 @@ from invokeai.app.services.board_images.board_images_default import BoardImagesS
|
||||
from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage
|
||||
from invokeai.app.services.boards.boards_default import BoardService
|
||||
from invokeai.app.services.bulk_download.bulk_download_default import BulkDownloadService
|
||||
from invokeai.app.services.client_state_persistence.client_state_persistence_sqlite import ClientStatePersistenceSqlite
|
||||
from invokeai.app.services.config.config_default import InvokeAIAppConfig
|
||||
from invokeai.app.services.download.download_default import DownloadQueueService
|
||||
from invokeai.app.services.events.events_fastapievents import FastAPIEventService
|
||||
@@ -151,6 +152,7 @@ class ApiDependencies:
|
||||
style_preset_records = SqliteStylePresetRecordsStorage(db=db)
|
||||
style_preset_image_files = StylePresetImageFileStorageDisk(style_presets_folder / "images")
|
||||
workflow_thumbnails = WorkflowThumbnailFileStorageDisk(workflow_thumbnails_folder)
|
||||
client_state_persistence = ClientStatePersistenceSqlite(db=db)
|
||||
|
||||
services = InvocationServices(
|
||||
board_image_records=board_image_records,
|
||||
@@ -181,6 +183,7 @@ class ApiDependencies:
|
||||
style_preset_records=style_preset_records,
|
||||
style_preset_image_files=style_preset_image_files,
|
||||
workflow_thumbnails=workflow_thumbnails,
|
||||
client_state_persistence=client_state_persistence,
|
||||
)
|
||||
|
||||
ApiDependencies.invoker = Invoker(services)
|
||||
|
||||
58
invokeai/app/api/routers/client_state.py
Normal file
58
invokeai/app/api/routers/client_state.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from fastapi import Body, HTTPException, Path, Query
|
||||
from fastapi.routing import APIRouter
|
||||
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.backend.util.logging import logging
|
||||
|
||||
client_state_router = APIRouter(prefix="/v1/client_state", tags=["client_state"])
|
||||
|
||||
|
||||
@client_state_router.get(
|
||||
"/{queue_id}/get_by_key",
|
||||
operation_id="get_client_state_by_key",
|
||||
response_model=str | None,
|
||||
)
|
||||
async def get_client_state_by_key(
|
||||
queue_id: str = Path(description="The queue id to perform this operation on"),
|
||||
key: str = Query(..., description="Key to get"),
|
||||
) -> str | None:
|
||||
"""Gets the client state"""
|
||||
try:
|
||||
return ApiDependencies.invoker.services.client_state_persistence.get_by_key(queue_id, key)
|
||||
except Exception as e:
|
||||
logging.error(f"Error getting client state: {e}")
|
||||
raise HTTPException(status_code=500, detail="Error setting client state")
|
||||
|
||||
|
||||
@client_state_router.post(
|
||||
"/{queue_id}/set_by_key",
|
||||
operation_id="set_client_state",
|
||||
response_model=str,
|
||||
)
|
||||
async def set_client_state(
|
||||
queue_id: str = Path(description="The queue id to perform this operation on"),
|
||||
key: str = Query(..., description="Key to set"),
|
||||
value: str = Body(..., description="Stringified value to set"),
|
||||
) -> str:
|
||||
"""Sets the client state"""
|
||||
try:
|
||||
return ApiDependencies.invoker.services.client_state_persistence.set_by_key(queue_id, key, value)
|
||||
except Exception as e:
|
||||
logging.error(f"Error setting client state: {e}")
|
||||
raise HTTPException(status_code=500, detail="Error setting client state")
|
||||
|
||||
|
||||
@client_state_router.post(
|
||||
"/{queue_id}/delete",
|
||||
operation_id="delete_client_state",
|
||||
responses={204: {"description": "Client state deleted"}},
|
||||
)
|
||||
async def delete_client_state(
|
||||
queue_id: str = Path(description="The queue id to perform this operation on"),
|
||||
) -> None:
|
||||
"""Deletes the client state"""
|
||||
try:
|
||||
ApiDependencies.invoker.services.client_state_persistence.delete(queue_id)
|
||||
except Exception as e:
|
||||
logging.error(f"Error deleting client state: {e}")
|
||||
raise HTTPException(status_code=500, detail="Error deleting client state")
|
||||
@@ -19,6 +19,7 @@ from invokeai.app.api.routers import (
|
||||
app_info,
|
||||
board_images,
|
||||
boards,
|
||||
client_state,
|
||||
download_queue,
|
||||
images,
|
||||
model_manager,
|
||||
@@ -131,6 +132,7 @@ app.include_router(app_info.app_router, prefix="/api")
|
||||
app.include_router(session_queue.session_queue_router, prefix="/api")
|
||||
app.include_router(workflows.workflows_router, prefix="/api")
|
||||
app.include_router(style_presets.style_presets_router, prefix="/api")
|
||||
app.include_router(client_state.client_state_router, prefix="/api")
|
||||
|
||||
app.openapi = get_openapi_func(app)
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ from invokeai.backend.util.devices import TorchDevice
|
||||
title="FLUX Denoise",
|
||||
tags=["image", "flux"],
|
||||
category="image",
|
||||
version="4.0.0",
|
||||
version="4.1.0",
|
||||
)
|
||||
class FluxDenoiseInvocation(BaseInvocation):
|
||||
"""Run denoising process with a FLUX transformer model."""
|
||||
@@ -153,7 +153,7 @@ class FluxDenoiseInvocation(BaseInvocation):
|
||||
description=FieldDescriptions.ip_adapter, title="IP-Adapter", default=None, input=Input.Connection
|
||||
)
|
||||
|
||||
kontext_conditioning: Optional[FluxKontextConditioningField] = InputField(
|
||||
kontext_conditioning: FluxKontextConditioningField | list[FluxKontextConditioningField] | None = InputField(
|
||||
default=None,
|
||||
description="FLUX Kontext conditioning (reference image).",
|
||||
input=Input.Connection,
|
||||
@@ -386,13 +386,15 @@ class FluxDenoiseInvocation(BaseInvocation):
|
||||
)
|
||||
|
||||
kontext_extension = None
|
||||
if self.kontext_conditioning is not None:
|
||||
if self.kontext_conditioning:
|
||||
if not self.controlnet_vae:
|
||||
raise ValueError("A VAE (e.g., controlnet_vae) must be provided to use Kontext conditioning.")
|
||||
|
||||
kontext_extension = KontextExtension(
|
||||
context=context,
|
||||
kontext_conditioning=self.kontext_conditioning,
|
||||
kontext_conditioning=self.kontext_conditioning
|
||||
if isinstance(self.kontext_conditioning, list)
|
||||
else [self.kontext_conditioning],
|
||||
vae_field=self.controlnet_vae,
|
||||
device=TorchDevice.choose_torch_device(),
|
||||
dtype=inference_dtype,
|
||||
|
||||
@@ -1347,3 +1347,96 @@ class PasteImageIntoBoundingBoxInvocation(BaseInvocation, WithMetadata, WithBoar
|
||||
|
||||
image_dto = context.images.save(image=target_image)
|
||||
return ImageOutput.build(image_dto)
|
||||
|
||||
|
||||
@invocation(
|
||||
"flux_kontext_image_prep",
|
||||
title="FLUX Kontext Image Prep",
|
||||
tags=["image", "concatenate", "flux", "kontext"],
|
||||
category="image",
|
||||
version="1.0.0",
|
||||
)
|
||||
class FluxKontextConcatenateImagesInvocation(BaseInvocation, WithMetadata, WithBoard):
|
||||
"""Prepares an image or images for use with FLUX Kontext. The first/single image is resized to the nearest
|
||||
preferred Kontext resolution. All other images are concatenated horizontally, maintaining their aspect ratio."""
|
||||
|
||||
images: list[ImageField] = InputField(
|
||||
description="The images to concatenate",
|
||||
min_length=1,
|
||||
max_length=10,
|
||||
)
|
||||
|
||||
use_preferred_resolution: bool = InputField(
|
||||
default=True, description="Use FLUX preferred resolutions for the first image"
|
||||
)
|
||||
|
||||
def invoke(self, context: InvocationContext) -> ImageOutput:
|
||||
from invokeai.backend.flux.util import PREFERED_KONTEXT_RESOLUTIONS
|
||||
|
||||
# Step 1: Load all images
|
||||
pil_images = []
|
||||
for image_field in self.images:
|
||||
image = context.images.get_pil(image_field.image_name, mode="RGBA")
|
||||
pil_images.append(image)
|
||||
|
||||
# Step 2: Determine target resolution for the first image
|
||||
first_image = pil_images[0]
|
||||
width, height = first_image.size
|
||||
|
||||
if self.use_preferred_resolution:
|
||||
aspect_ratio = width / height
|
||||
|
||||
# Find the closest preferred resolution for the first image
|
||||
_, target_width, target_height = min(
|
||||
((abs(aspect_ratio - w / h), w, h) for w, h in PREFERED_KONTEXT_RESOLUTIONS), key=lambda x: x[0]
|
||||
)
|
||||
|
||||
# Apply BFL's scaling formula
|
||||
scaled_height = 2 * int(target_height / 16)
|
||||
final_height = 8 * scaled_height # This will be consistent for all images
|
||||
scaled_width = 2 * int(target_width / 16)
|
||||
first_width = 8 * scaled_width
|
||||
else:
|
||||
# Use original dimensions of first image, ensuring divisibility by 16
|
||||
final_height = 16 * (height // 16)
|
||||
first_width = 16 * (width // 16)
|
||||
# Ensure minimum dimensions
|
||||
if final_height < 16:
|
||||
final_height = 16
|
||||
if first_width < 16:
|
||||
first_width = 16
|
||||
|
||||
# Step 3: Process and resize all images with consistent height
|
||||
processed_images = []
|
||||
total_width = 0
|
||||
|
||||
for i, image in enumerate(pil_images):
|
||||
if i == 0:
|
||||
# First image uses the calculated dimensions
|
||||
final_width = first_width
|
||||
else:
|
||||
# Subsequent images maintain aspect ratio with the same height
|
||||
img_aspect_ratio = image.width / image.height
|
||||
# Calculate width that maintains aspect ratio at the target height
|
||||
calculated_width = int(final_height * img_aspect_ratio)
|
||||
# Ensure width is divisible by 16 for proper VAE encoding
|
||||
final_width = 16 * (calculated_width // 16)
|
||||
# Ensure minimum width
|
||||
if final_width < 16:
|
||||
final_width = 16
|
||||
|
||||
# Resize image to calculated dimensions
|
||||
resized_image = image.resize((final_width, final_height), Image.Resampling.LANCZOS)
|
||||
processed_images.append(resized_image)
|
||||
total_width += final_width
|
||||
|
||||
# Step 4: Concatenate images horizontally
|
||||
concatenated_image = Image.new("RGB", (total_width, final_height))
|
||||
x_offset = 0
|
||||
for img in processed_images:
|
||||
concatenated_image.paste(img, (x_offset, 0))
|
||||
x_offset += img.width
|
||||
|
||||
# Save the concatenated image
|
||||
image_dto = context.images.save(image=concatenated_image)
|
||||
return ImageOutput.build(image_dto)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class ClientStatePersistenceABC(ABC):
|
||||
"""
|
||||
Base class for client persistence implementations.
|
||||
This class defines the interface for persisting client data.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def set_by_key(self, queue_id: str, key: str, value: str) -> str:
|
||||
"""
|
||||
Set a key-value pair for the client.
|
||||
|
||||
Args:
|
||||
key (str): The key to set.
|
||||
value (str): The value to set for the key.
|
||||
|
||||
Returns:
|
||||
str: The value that was set.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_by_key(self, queue_id: str, key: str) -> str | None:
|
||||
"""
|
||||
Get the value for a specific key of the client.
|
||||
|
||||
Args:
|
||||
key (str): The key to retrieve the value for.
|
||||
|
||||
Returns:
|
||||
str | None: The value associated with the key, or None if the key does not exist.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, queue_id: str) -> None:
|
||||
"""
|
||||
Delete all client state.
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,65 @@
|
||||
import json
|
||||
|
||||
from invokeai.app.services.client_state_persistence.client_state_persistence_base import ClientStatePersistenceABC
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
|
||||
|
||||
|
||||
class ClientStatePersistenceSqlite(ClientStatePersistenceABC):
|
||||
"""
|
||||
Base class for client persistence implementations.
|
||||
This class defines the interface for persisting client data.
|
||||
"""
|
||||
|
||||
def __init__(self, db: SqliteDatabase) -> None:
|
||||
super().__init__()
|
||||
self._db = db
|
||||
self._default_row_id = 1
|
||||
|
||||
def start(self, invoker: Invoker) -> None:
|
||||
self._invoker = invoker
|
||||
|
||||
def _get(self) -> dict[str, str] | None:
|
||||
with self._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT data FROM client_state
|
||||
WHERE id = {self._default_row_id}
|
||||
"""
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return json.loads(row[0])
|
||||
|
||||
def set_by_key(self, queue_id: str, key: str, value: str) -> str:
|
||||
state = self._get() or {}
|
||||
state.update({key: value})
|
||||
|
||||
with self._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
f"""
|
||||
INSERT INTO client_state (id, data)
|
||||
VALUES ({self._default_row_id}, ?)
|
||||
ON CONFLICT(id) DO UPDATE
|
||||
SET data = excluded.data;
|
||||
""",
|
||||
(json.dumps(state),),
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
def get_by_key(self, queue_id: str, key: str) -> str | None:
|
||||
state = self._get()
|
||||
if state is None:
|
||||
return None
|
||||
return state.get(key, None)
|
||||
|
||||
def delete(self, queue_id: str) -> None:
|
||||
with self._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
f"""
|
||||
DELETE FROM client_state
|
||||
WHERE id = {self._default_row_id}
|
||||
"""
|
||||
)
|
||||
@@ -17,6 +17,7 @@ if TYPE_CHECKING:
|
||||
from invokeai.app.services.board_records.board_records_base import BoardRecordStorageBase
|
||||
from invokeai.app.services.boards.boards_base import BoardServiceABC
|
||||
from invokeai.app.services.bulk_download.bulk_download_base import BulkDownloadBase
|
||||
from invokeai.app.services.client_state_persistence.client_state_persistence_base import ClientStatePersistenceABC
|
||||
from invokeai.app.services.config import InvokeAIAppConfig
|
||||
from invokeai.app.services.download import DownloadQueueServiceBase
|
||||
from invokeai.app.services.events.events_base import EventServiceBase
|
||||
@@ -73,6 +74,7 @@ class InvocationServices:
|
||||
style_preset_records: "StylePresetRecordsStorageBase",
|
||||
style_preset_image_files: "StylePresetImageFileStorageBase",
|
||||
workflow_thumbnails: "WorkflowThumbnailServiceBase",
|
||||
client_state_persistence: "ClientStatePersistenceABC",
|
||||
):
|
||||
self.board_images = board_images
|
||||
self.board_image_records = board_image_records
|
||||
@@ -102,3 +104,4 @@ class InvocationServices:
|
||||
self.style_preset_records = style_preset_records
|
||||
self.style_preset_image_files = style_preset_image_files
|
||||
self.workflow_thumbnails = workflow_thumbnails
|
||||
self.client_state_persistence = client_state_persistence
|
||||
|
||||
@@ -7,7 +7,7 @@ import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from queue import Empty, Queue
|
||||
from shutil import copyfile, copytree, move, rmtree
|
||||
from shutil import move, rmtree
|
||||
from tempfile import mkdtemp
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Type, Union
|
||||
|
||||
@@ -186,13 +186,14 @@ class ModelInstallService(ModelInstallServiceBase):
|
||||
info: AnyModelConfig = self._probe(Path(model_path), config) # type: ignore
|
||||
|
||||
if preferred_name := config.name:
|
||||
preferred_name = Path(preferred_name).with_suffix(model_path.suffix)
|
||||
# Careful! Don't use pathlib.Path(...).with_suffix - it can will strip everything after the first dot.
|
||||
preferred_name = f"{preferred_name}{model_path.suffix}"
|
||||
|
||||
dest_path = (
|
||||
self.app_config.models_path / info.base.value / info.type.value / (preferred_name or model_path.name)
|
||||
)
|
||||
try:
|
||||
new_path = self._copy_model(model_path, dest_path)
|
||||
new_path = self._move_model(model_path, dest_path)
|
||||
except FileExistsError as excp:
|
||||
raise DuplicateModelException(
|
||||
f"A model named {model_path.name} is already installed at {dest_path.as_posix()}"
|
||||
@@ -617,16 +618,6 @@ class ModelInstallService(ModelInstallServiceBase):
|
||||
self.record_store.update_model(key, ModelRecordChanges(path=model.path))
|
||||
return model
|
||||
|
||||
def _copy_model(self, old_path: Path, new_path: Path) -> Path:
|
||||
if old_path == new_path:
|
||||
return old_path
|
||||
new_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if old_path.is_dir():
|
||||
copytree(old_path, new_path)
|
||||
else:
|
||||
copyfile(old_path, new_path)
|
||||
return new_path
|
||||
|
||||
def _move_model(self, old_path: Path, new_path: Path) -> Path:
|
||||
if old_path == new_path:
|
||||
return old_path
|
||||
|
||||
@@ -23,6 +23,7 @@ from invokeai.app.services.shared.sqlite_migrator.migrations.migration_17 import
|
||||
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_18 import build_migration_18
|
||||
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_19 import build_migration_19
|
||||
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_20 import build_migration_20
|
||||
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_21 import build_migration_21
|
||||
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import SqliteMigrator
|
||||
|
||||
|
||||
@@ -63,6 +64,7 @@ def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileSto
|
||||
migrator.register_migration(build_migration_18())
|
||||
migrator.register_migration(build_migration_19(app_config=config))
|
||||
migrator.register_migration(build_migration_20())
|
||||
migrator.register_migration(build_migration_21())
|
||||
migrator.run_migrations()
|
||||
|
||||
return db
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import sqlite3
|
||||
|
||||
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
|
||||
|
||||
|
||||
class Migration21Callback:
|
||||
def __call__(self, cursor: sqlite3.Cursor) -> None:
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE client_state (
|
||||
id INTEGER PRIMARY KEY CHECK(id = 1),
|
||||
data TEXT NOT NULL, -- Frontend will handle the shape of this data
|
||||
updated_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP)
|
||||
);
|
||||
"""
|
||||
)
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TRIGGER tg_client_state_updated_at
|
||||
AFTER UPDATE ON client_state
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE client_state
|
||||
SET updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = OLD.id;
|
||||
END;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def build_migration_21() -> Migration:
|
||||
"""Builds the migration object for migrating from version 20 to version 21. This includes:
|
||||
- Creating the `client_state` table.
|
||||
- Adding a trigger to update the `updated_at` field on updates.
|
||||
"""
|
||||
return Migration(
|
||||
from_version=20,
|
||||
to_version=21,
|
||||
callback=Migration21Callback(),
|
||||
)
|
||||
@@ -112,7 +112,7 @@ def denoise(
|
||||
)
|
||||
|
||||
# Slice prediction to only include the main image tokens
|
||||
if img_input_ids is not None:
|
||||
if img_cond_seq is not None:
|
||||
pred = pred[:, :original_seq_len]
|
||||
|
||||
step_cfg_scale = cfg_scale[step_index]
|
||||
@@ -125,9 +125,26 @@ def denoise(
|
||||
if neg_regional_prompting_extension is None:
|
||||
raise ValueError("Negative text conditioning is required when cfg_scale is not 1.0.")
|
||||
|
||||
# For negative prediction with Kontext, we need to include the reference images
|
||||
# to maintain consistency between positive and negative passes. Without this,
|
||||
# CFG would create artifacts as the attention mechanism would see different
|
||||
# spatial structures in each pass
|
||||
neg_img_input = img
|
||||
neg_img_input_ids = img_ids
|
||||
|
||||
# Add channel-wise conditioning for negative pass if present
|
||||
if img_cond is not None:
|
||||
neg_img_input = torch.cat((neg_img_input, img_cond), dim=-1)
|
||||
|
||||
# Add sequence-wise conditioning (Kontext) for negative pass
|
||||
# This ensures reference images are processed consistently
|
||||
if img_cond_seq is not None:
|
||||
neg_img_input = torch.cat((neg_img_input, img_cond_seq), dim=1)
|
||||
neg_img_input_ids = torch.cat((neg_img_input_ids, img_cond_seq_ids), dim=1)
|
||||
|
||||
neg_pred = model(
|
||||
img=img,
|
||||
img_ids=img_ids,
|
||||
img=neg_img_input,
|
||||
img_ids=neg_img_input_ids,
|
||||
txt=neg_regional_prompting_extension.regional_text_conditioning.t5_embeddings,
|
||||
txt_ids=neg_regional_prompting_extension.regional_text_conditioning.t5_txt_ids,
|
||||
y=neg_regional_prompting_extension.regional_text_conditioning.clip_embeddings,
|
||||
@@ -140,6 +157,10 @@ def denoise(
|
||||
ip_adapter_extensions=neg_ip_adapter_extensions,
|
||||
regional_prompting_extension=neg_regional_prompting_extension,
|
||||
)
|
||||
|
||||
# Slice negative prediction to match main image tokens
|
||||
if img_cond_seq is not None:
|
||||
neg_pred = neg_pred[:, :original_seq_len]
|
||||
pred = neg_pred + step_cfg_scale * (pred - neg_pred)
|
||||
|
||||
preview_img = img - t_curr * pred
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import einops
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
import torchvision.transforms as T
|
||||
from einops import repeat
|
||||
from PIL import Image
|
||||
|
||||
from invokeai.app.invocations.fields import FluxKontextConditioningField
|
||||
from invokeai.app.invocations.flux_vae_encode import FluxVaeEncodeInvocation
|
||||
from invokeai.app.invocations.model import VAEField
|
||||
from invokeai.app.services.shared.invocation_context import InvocationContext
|
||||
from invokeai.backend.flux.modules.autoencoder import AutoEncoder
|
||||
from invokeai.backend.flux.sampling_utils import pack
|
||||
from invokeai.backend.flux.util import PREFERED_KONTEXT_RESOLUTIONS
|
||||
from invokeai.backend.util.devices import TorchDevice
|
||||
|
||||
|
||||
def generate_img_ids_with_offset(
|
||||
@@ -19,8 +18,10 @@ def generate_img_ids_with_offset(
|
||||
device: torch.device,
|
||||
dtype: torch.dtype,
|
||||
idx_offset: int = 0,
|
||||
h_offset: int = 0,
|
||||
w_offset: int = 0,
|
||||
) -> torch.Tensor:
|
||||
"""Generate tensor of image position ids with an optional offset.
|
||||
"""Generate tensor of image position ids with optional index and spatial offsets.
|
||||
|
||||
Args:
|
||||
latent_height (int): Height of image in latent space (after packing, this becomes h//2).
|
||||
@@ -28,7 +29,9 @@ def generate_img_ids_with_offset(
|
||||
batch_size (int): Number of images in the batch.
|
||||
device (torch.device): Device to create tensors on.
|
||||
dtype (torch.dtype): Data type for the tensors.
|
||||
idx_offset (int): Offset to add to the first dimension of the image ids.
|
||||
idx_offset (int): Offset to add to the first dimension of the image ids (default: 0).
|
||||
h_offset (int): Spatial offset for height/y-coordinates in latent space (default: 0).
|
||||
w_offset (int): Spatial offset for width/x-coordinates in latent space (default: 0).
|
||||
|
||||
Returns:
|
||||
torch.Tensor: Image position ids with shape [batch_size, (latent_height//2 * latent_width//2), 3].
|
||||
@@ -42,6 +45,10 @@ def generate_img_ids_with_offset(
|
||||
packed_height = latent_height // 2
|
||||
packed_width = latent_width // 2
|
||||
|
||||
# Convert spatial offsets from latent space to packed space
|
||||
packed_h_offset = h_offset // 2
|
||||
packed_w_offset = w_offset // 2
|
||||
|
||||
# Create base tensor for position IDs with shape [packed_height, packed_width, 3]
|
||||
# The 3 channels represent: [batch_offset, y_position, x_position]
|
||||
img_ids = torch.zeros(packed_height, packed_width, 3, device=device, dtype=dtype)
|
||||
@@ -49,13 +56,13 @@ def generate_img_ids_with_offset(
|
||||
# Set the batch offset for all positions
|
||||
img_ids[..., 0] = idx_offset
|
||||
|
||||
# Create y-coordinate indices (vertical positions)
|
||||
y_indices = torch.arange(packed_height, device=device, dtype=dtype)
|
||||
# Create y-coordinate indices (vertical positions) with spatial offset
|
||||
y_indices = torch.arange(packed_height, device=device, dtype=dtype) + packed_h_offset
|
||||
# Broadcast y_indices to match the spatial dimensions [packed_height, 1]
|
||||
img_ids[..., 1] = y_indices[:, None]
|
||||
|
||||
# Create x-coordinate indices (horizontal positions)
|
||||
x_indices = torch.arange(packed_width, device=device, dtype=dtype)
|
||||
# Create x-coordinate indices (horizontal positions) with spatial offset
|
||||
x_indices = torch.arange(packed_width, device=device, dtype=dtype) + packed_w_offset
|
||||
# Broadcast x_indices to match the spatial dimensions [1, packed_width]
|
||||
img_ids[..., 2] = x_indices[None, :]
|
||||
|
||||
@@ -73,14 +80,14 @@ class KontextExtension:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
kontext_conditioning: FluxKontextConditioningField,
|
||||
kontext_conditioning: list[FluxKontextConditioningField],
|
||||
context: InvocationContext,
|
||||
vae_field: VAEField,
|
||||
device: torch.device,
|
||||
dtype: torch.dtype,
|
||||
):
|
||||
"""
|
||||
Initializes the KontextExtension, pre-processing the reference image
|
||||
Initializes the KontextExtension, pre-processing the reference images
|
||||
into latents and positional IDs.
|
||||
"""
|
||||
self._context = context
|
||||
@@ -93,54 +100,101 @@ class KontextExtension:
|
||||
self.kontext_latents, self.kontext_ids = self._prepare_kontext()
|
||||
|
||||
def _prepare_kontext(self) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Encodes the reference image and prepares its latents and IDs."""
|
||||
image = self._context.images.get_pil(self.kontext_conditioning.image.image_name)
|
||||
"""Encodes the reference images and prepares their concatenated latents and IDs with spatial tiling."""
|
||||
all_latents = []
|
||||
all_ids = []
|
||||
|
||||
# Calculate aspect ratio of input image
|
||||
width, height = image.size
|
||||
aspect_ratio = width / height
|
||||
# Track cumulative dimensions for spatial tiling
|
||||
# These track the running extent of the virtual canvas in latent space
|
||||
h = 0 # Running height extent
|
||||
w = 0 # Running width extent
|
||||
|
||||
# Find the closest preferred resolution by aspect ratio
|
||||
_, target_width, target_height = min(
|
||||
((abs(aspect_ratio - w / h), w, h) for w, h in PREFERED_KONTEXT_RESOLUTIONS), key=lambda x: x[0]
|
||||
)
|
||||
|
||||
# Apply BFL's scaling formula
|
||||
# This ensures compatibility with the model's training
|
||||
scaled_width = 2 * int(target_width / 16)
|
||||
scaled_height = 2 * int(target_height / 16)
|
||||
|
||||
# Resize to the exact resolution used during training
|
||||
image = image.convert("RGB")
|
||||
final_width = 8 * scaled_width
|
||||
final_height = 8 * scaled_height
|
||||
image = image.resize((final_width, final_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Convert to tensor with same normalization as BFL
|
||||
image_np = np.array(image)
|
||||
image_tensor = torch.from_numpy(image_np).float() / 127.5 - 1.0
|
||||
image_tensor = einops.rearrange(image_tensor, "h w c -> 1 c h w")
|
||||
image_tensor = image_tensor.to(self._device)
|
||||
|
||||
# Continue with VAE encoding
|
||||
vae_info = self._context.models.load(self._vae_field.vae)
|
||||
kontext_latents_unpacked = FluxVaeEncodeInvocation.vae_encode(vae_info=vae_info, image_tensor=image_tensor)
|
||||
|
||||
# Extract tensor dimensions
|
||||
batch_size, _, latent_height, latent_width = kontext_latents_unpacked.shape
|
||||
for idx, kontext_field in enumerate(self.kontext_conditioning):
|
||||
image = self._context.images.get_pil(kontext_field.image.image_name)
|
||||
|
||||
# Pack the latents and generate IDs
|
||||
kontext_latents_packed = pack(kontext_latents_unpacked).to(self._device, self._dtype)
|
||||
kontext_ids = generate_img_ids_with_offset(
|
||||
latent_height=latent_height,
|
||||
latent_width=latent_width,
|
||||
batch_size=batch_size,
|
||||
device=self._device,
|
||||
dtype=self._dtype,
|
||||
idx_offset=1,
|
||||
)
|
||||
# Convert to RGB
|
||||
image = image.convert("RGB")
|
||||
|
||||
return kontext_latents_packed, kontext_ids
|
||||
# Convert to tensor using torchvision transforms for consistency
|
||||
transformation = T.Compose(
|
||||
[
|
||||
T.ToTensor(), # Converts PIL image to tensor and scales to [0, 1]
|
||||
]
|
||||
)
|
||||
image_tensor = transformation(image)
|
||||
# Convert from [0, 1] to [-1, 1] range expected by VAE
|
||||
image_tensor = image_tensor * 2.0 - 1.0
|
||||
image_tensor = image_tensor.unsqueeze(0) # Add batch dimension
|
||||
image_tensor = image_tensor.to(self._device)
|
||||
|
||||
# Continue with VAE encoding
|
||||
# Don't sample from the distribution for reference images - use the mean (matching ComfyUI)
|
||||
with vae_info as vae:
|
||||
assert isinstance(vae, AutoEncoder)
|
||||
vae_dtype = next(iter(vae.parameters())).dtype
|
||||
image_tensor = image_tensor.to(device=TorchDevice.choose_torch_device(), dtype=vae_dtype)
|
||||
# Use sample=False to get the distribution mean without noise
|
||||
kontext_latents_unpacked = vae.encode(image_tensor, sample=False)
|
||||
|
||||
# Extract tensor dimensions
|
||||
batch_size, _, latent_height, latent_width = kontext_latents_unpacked.shape
|
||||
|
||||
# Pad latents to be compatible with patch_size=2
|
||||
# This ensures dimensions are even for the pack() function
|
||||
pad_h = (2 - latent_height % 2) % 2
|
||||
pad_w = (2 - latent_width % 2) % 2
|
||||
if pad_h > 0 or pad_w > 0:
|
||||
kontext_latents_unpacked = F.pad(kontext_latents_unpacked, (0, pad_w, 0, pad_h), mode="circular")
|
||||
# Update dimensions after padding
|
||||
_, _, latent_height, latent_width = kontext_latents_unpacked.shape
|
||||
|
||||
# Pack the latents
|
||||
kontext_latents_packed = pack(kontext_latents_unpacked).to(self._device, self._dtype)
|
||||
|
||||
# Determine spatial offsets for this reference image
|
||||
# - Compare the potential new canvas dimensions if we add the image vertically vs horizontally
|
||||
# - Choose the placement that results in a more square-like canvas
|
||||
h_offset = 0
|
||||
w_offset = 0
|
||||
|
||||
if idx > 0: # First image starts at (0, 0)
|
||||
# Check which placement would result in better canvas dimensions
|
||||
# If adding to height would make the canvas taller than wide, tile horizontally
|
||||
# Otherwise, tile vertically
|
||||
if latent_height + h > latent_width + w:
|
||||
# Tile horizontally (to the right of existing images)
|
||||
w_offset = w
|
||||
else:
|
||||
# Tile vertically (below existing images)
|
||||
h_offset = h
|
||||
|
||||
# Generate IDs with both index offset and spatial offsets
|
||||
kontext_ids = generate_img_ids_with_offset(
|
||||
latent_height=latent_height,
|
||||
latent_width=latent_width,
|
||||
batch_size=batch_size,
|
||||
device=self._device,
|
||||
dtype=self._dtype,
|
||||
idx_offset=1, # All reference images use index=1 (matching ComfyUI implementation)
|
||||
h_offset=h_offset,
|
||||
w_offset=w_offset,
|
||||
)
|
||||
|
||||
# Update cumulative dimensions
|
||||
# Track the maximum extent of the virtual canvas after placing this image
|
||||
h = max(h, latent_height + h_offset)
|
||||
w = max(w, latent_width + w_offset)
|
||||
|
||||
all_latents.append(kontext_latents_packed)
|
||||
all_ids.append(kontext_ids)
|
||||
|
||||
# Concatenate all latents and IDs along the sequence dimension
|
||||
concatenated_latents = torch.cat(all_latents, dim=1) # Concatenate along sequence dimension
|
||||
concatenated_ids = torch.cat(all_ids, dim=1) # Concatenate along sequence dimension
|
||||
|
||||
return concatenated_latents, concatenated_ids
|
||||
|
||||
def ensure_batch_size(self, target_batch_size: int) -> None:
|
||||
"""Ensures the kontext latents and IDs match the target batch size by repeating if necessary."""
|
||||
|
||||
3
invokeai/frontend/web/.gitignore
vendored
3
invokeai/frontend/web/.gitignore
vendored
@@ -44,4 +44,5 @@ yalc.lock
|
||||
|
||||
# vitest
|
||||
tsconfig.vitest-temp.json
|
||||
coverage/
|
||||
coverage/
|
||||
*.tgz
|
||||
|
||||
@@ -26,7 +26,7 @@ i18n.use(initReactI18next).init({
|
||||
returnNull: false,
|
||||
});
|
||||
|
||||
const store = createStore(undefined, false);
|
||||
const store = createStore();
|
||||
$store.set(store);
|
||||
$baseUrl.set('http://localhost:9090');
|
||||
|
||||
|
||||
@@ -197,6 +197,10 @@ export default [
|
||||
importNames: ['isEqual'],
|
||||
message: 'Please use objectEquals from @observ33r/object-equals instead.',
|
||||
},
|
||||
{
|
||||
name: 'zod/v3',
|
||||
message: 'Import from zod instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -17,6 +17,7 @@ const config: KnipConfig = {
|
||||
'src/app/store/use-debounced-app-selector.ts',
|
||||
],
|
||||
ignoreBinaries: ['only-allow'],
|
||||
ignoreDependencies: ['magic-string'],
|
||||
paths: {
|
||||
'public/*': ['public/*'],
|
||||
},
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
"framer-motion": "^11.10.0",
|
||||
"i18next": "^25.3.2",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"idb-keyval": "6.2.2",
|
||||
"idb-keyval": "6.2.1",
|
||||
"jsondiffpatch": "^0.7.3",
|
||||
"konva": "^9.3.22",
|
||||
"linkify-react": "^4.3.1",
|
||||
@@ -103,7 +103,7 @@
|
||||
"use-debounce": "^10.0.5",
|
||||
"use-device-pixel-ratio": "^1.1.2",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^4.0.5",
|
||||
"zod": "^4.0.10",
|
||||
"zod-validation-error": "^3.5.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -139,6 +139,7 @@
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"globals": "^16.3.0",
|
||||
"knip": "^5.61.3",
|
||||
"magic-string": "^0.30.17",
|
||||
"openapi-types": "^12.1.3",
|
||||
"openapi-typescript": "^7.6.1",
|
||||
"prettier": "^3.5.3",
|
||||
|
||||
37
invokeai/frontend/web/pnpm-lock.yaml
generated
37
invokeai/frontend/web/pnpm-lock.yaml
generated
@@ -81,8 +81,8 @@ importers:
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2
|
||||
idb-keyval:
|
||||
specifier: 6.2.2
|
||||
version: 6.2.2
|
||||
specifier: 6.2.1
|
||||
version: 6.2.1
|
||||
jsondiffpatch:
|
||||
specifier: ^0.7.3
|
||||
version: 0.7.3
|
||||
@@ -201,11 +201,11 @@ importers:
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.0
|
||||
zod:
|
||||
specifier: ^4.0.5
|
||||
version: 4.0.5
|
||||
specifier: ^4.0.10
|
||||
version: 4.0.10
|
||||
zod-validation-error:
|
||||
specifier: ^3.5.2
|
||||
version: 3.5.3(zod@4.0.5)
|
||||
version: 3.5.3(zod@4.0.10)
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^9.31.0
|
||||
@@ -291,6 +291,9 @@ importers:
|
||||
knip:
|
||||
specifier: ^5.61.3
|
||||
version: 5.61.3(@types/node@22.16.0)(typescript@5.8.3)
|
||||
magic-string:
|
||||
specifier: ^0.30.17
|
||||
version: 0.30.17
|
||||
openapi-types:
|
||||
specifier: ^12.1.3
|
||||
version: 12.1.3
|
||||
@@ -411,6 +414,10 @@ packages:
|
||||
resolution: {integrity: sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/runtime@7.28.2':
|
||||
resolution: {integrity: sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/template@7.27.2':
|
||||
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -2771,8 +2778,8 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
idb-keyval@6.2.2:
|
||||
resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==}
|
||||
idb-keyval@6.2.1:
|
||||
resolution: {integrity: sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==}
|
||||
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
@@ -4511,8 +4518,8 @@ packages:
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
zod@4.0.5:
|
||||
resolution: {integrity: sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA==}
|
||||
zod@4.0.10:
|
||||
resolution: {integrity: sha512-3vB+UU3/VmLL2lvwcY/4RV2i9z/YU0DTV/tDuYjrwmx5WeJ7hwy+rGEEx8glHp6Yxw7ibRbKSaIFBgReRPe5KA==}
|
||||
|
||||
zustand@4.5.7:
|
||||
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
|
||||
@@ -4633,6 +4640,8 @@ snapshots:
|
||||
|
||||
'@babel/runtime@7.27.6': {}
|
||||
|
||||
'@babel/runtime@7.28.2': {}
|
||||
|
||||
'@babel/template@7.27.2':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.27.1
|
||||
@@ -5736,7 +5745,7 @@ snapshots:
|
||||
'@testing-library/dom@10.4.0':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.27.1
|
||||
'@babel/runtime': 7.27.6
|
||||
'@babel/runtime': 7.28.2
|
||||
'@types/aria-query': 5.0.4
|
||||
aria-query: 5.3.0
|
||||
chalk: 4.1.2
|
||||
@@ -7266,7 +7275,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.8.3
|
||||
|
||||
idb-keyval@6.2.2: {}
|
||||
idb-keyval@6.2.1: {}
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
@@ -9062,13 +9071,13 @@ snapshots:
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
zod-validation-error@3.5.3(zod@4.0.5):
|
||||
zod-validation-error@3.5.3(zod@4.0.10):
|
||||
dependencies:
|
||||
zod: 4.0.5
|
||||
zod: 4.0.10
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zod@4.0.5: {}
|
||||
zod@4.0.10: {}
|
||||
|
||||
zustand@4.5.7(@types/react@18.3.23)(immer@10.1.1)(react@18.3.1):
|
||||
dependencies:
|
||||
|
||||
@@ -1235,7 +1235,7 @@
|
||||
"modelIncompatibleScaledBboxWidth": "Scaled bbox width is {{width}} but {{model}} requires multiple of {{multiple}}",
|
||||
"modelIncompatibleScaledBboxHeight": "Scaled bbox height is {{height}} but {{model}} requires multiple of {{multiple}}",
|
||||
"fluxModelMultipleControlLoRAs": "Can only use 1 Control LoRA at a time",
|
||||
"fluxKontextMultipleReferenceImages": "Can only use 1 Reference Image at a time with Flux Kontext",
|
||||
"fluxKontextMultipleReferenceImages": "Can only use 1 Reference Image at a time with FLUX Kontext via BFL API",
|
||||
"canvasIsFiltering": "Canvas is busy (filtering)",
|
||||
"canvasIsTransforming": "Canvas is busy (transforming)",
|
||||
"canvasIsRasterizing": "Canvas is busy (rasterizing)",
|
||||
@@ -2066,6 +2066,8 @@
|
||||
"asControlLayer": "As $t(controlLayers.controlLayer)",
|
||||
"asControlLayerResize": "As $t(controlLayers.controlLayer) (Resize)",
|
||||
"referenceImage": "Reference Image",
|
||||
"maxRefImages": "Max Ref Images",
|
||||
"useAsReferenceImage": "Use as Reference Image",
|
||||
"regionalReferenceImage": "Regional Reference Image",
|
||||
"globalReferenceImage": "Global Reference Image",
|
||||
"sendingToCanvas": "Staging Generations on Canvas",
|
||||
@@ -2533,7 +2535,7 @@
|
||||
},
|
||||
"ui": {
|
||||
"tabs": {
|
||||
"generation": "Generation",
|
||||
"generate": "Generate",
|
||||
"canvas": "Canvas",
|
||||
"workflows": "Workflows",
|
||||
"workflowsTab": "$t(ui.tabs.workflows) $t(common.tab)",
|
||||
@@ -2544,6 +2546,12 @@
|
||||
"upscalingTab": "$t(ui.tabs.upscaling) $t(common.tab)",
|
||||
"gallery": "Gallery"
|
||||
},
|
||||
"panels": {
|
||||
"launchpad": "Launchpad",
|
||||
"workflowEditor": "Workflow Editor",
|
||||
"imageViewer": "Image Viewer",
|
||||
"canvas": "Canvas"
|
||||
},
|
||||
"launchpad": {
|
||||
"workflowsTitle": "Go deep with Workflows.",
|
||||
"upscalingTitle": "Upscale and add detail.",
|
||||
@@ -2551,6 +2559,28 @@
|
||||
"generateTitle": "Generate images from text prompts.",
|
||||
"modelGuideText": "Want to learn what prompts work best for each model?",
|
||||
"modelGuideLink": "Check out our Model Guide.",
|
||||
"createNewWorkflowFromScratch": "Create a new Workflow from scratch",
|
||||
"browseAndLoadWorkflows": "Browse and load existing workflows",
|
||||
"addStyleRef": {
|
||||
"title": "Add a Style Reference",
|
||||
"description": "Add an image to transfer its look."
|
||||
},
|
||||
"editImage": {
|
||||
"title": "Edit Image",
|
||||
"description": "Add an image to refine."
|
||||
},
|
||||
"generateFromText": {
|
||||
"title": "Generate from Text",
|
||||
"description": "Enter a prompt and Invoke."
|
||||
},
|
||||
"useALayoutImage": {
|
||||
"title": "Use a Layout Image",
|
||||
"description": "Add an image to control composition."
|
||||
},
|
||||
"generate": {
|
||||
"canvasCalloutTitle": "Looking to get more control, edit, and iterate on your images?",
|
||||
"canvasCalloutLink": "Navigate to Canvas for more capabilities."
|
||||
},
|
||||
"workflows": {
|
||||
"description": "Workflows are reusable templates that automate image generation tasks, allowing you to quickly perform complex operations and get consistent results.",
|
||||
"learnMoreLink": "Learn more about creating workflows",
|
||||
@@ -2587,6 +2617,13 @@
|
||||
"upscaleModel": "Upscale Model",
|
||||
"model": "Model",
|
||||
"scale": "Scale",
|
||||
"creativityAndStructure": {
|
||||
"title": "Creativity & Structure Defaults",
|
||||
"conservative": "Conservative",
|
||||
"balanced": "Balanced",
|
||||
"creative": "Creative",
|
||||
"artistic": "Artistic"
|
||||
},
|
||||
"helpText": {
|
||||
"promptAdvice": "When upscaling, use a prompt that describes the medium and style. Avoid describing specific content details in the image.",
|
||||
"styleAdvice": "Upscaling works best with the general style of your image."
|
||||
@@ -2631,10 +2668,8 @@
|
||||
"whatsNew": {
|
||||
"whatsNewInInvoke": "What's New in Invoke",
|
||||
"items": [
|
||||
"New setting to send all Canvas generations directly to the Gallery.",
|
||||
"New Invert Mask (Shift+V) and Fit BBox to Mask (Shift+B) capabilities.",
|
||||
"Expanded support for Model Thumbnails and configurations.",
|
||||
"Various other quality of life updates and fixes"
|
||||
"Studio state is saved to the server, allowing you to continue your work on any device.",
|
||||
"Support for multiple reference images for FLUX Kontext (local model only)."
|
||||
],
|
||||
"readReleaseNotes": "Read Release Notes",
|
||||
"watchRecentReleaseVideos": "Watch Recent Release Videos",
|
||||
|
||||
@@ -254,8 +254,8 @@
|
||||
"desc": "Attiva/disattiva il pannello destro."
|
||||
},
|
||||
"resetPanelLayout": {
|
||||
"title": "Ripristina il layout del pannello",
|
||||
"desc": "Ripristina le dimensioni e il layout predefiniti dei pannelli sinistro e destro."
|
||||
"title": "Ripristina lo schema del pannello",
|
||||
"desc": "Ripristina le dimensioni e lo schema predefiniti dei pannelli sinistro e destro."
|
||||
},
|
||||
"togglePanels": {
|
||||
"title": "Attiva/disattiva i pannelli",
|
||||
@@ -539,6 +539,10 @@
|
||||
"galleryNavUpAlt": {
|
||||
"desc": "Uguale a Naviga verso l'alto, ma seleziona l'immagine da confrontare, aprendo la modalità di confronto se non è già aperta.",
|
||||
"title": "Naviga verso l'alto (Confronta immagine)"
|
||||
},
|
||||
"starImage": {
|
||||
"desc": "Aggiungi/Rimuovi contrassegno all'immagine selezionata.",
|
||||
"title": "Aggiungi / Rimuovi contrassegno immagine"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -794,7 +798,7 @@
|
||||
"modelIncompatibleScaledBboxWidth": "La larghezza scalata del riquadro è {{width}} ma {{model}} richiede multipli di {{multiple}}",
|
||||
"modelIncompatibleScaledBboxHeight": "L'altezza scalata del riquadro è {{height}} ma {{model}} richiede multipli di {{multiple}}",
|
||||
"modelDisabledForTrial": "La generazione con {{modelName}} non è disponibile per gli account di prova. Accedi alle impostazioni del tuo account per effettuare l'upgrade.",
|
||||
"fluxKontextMultipleReferenceImages": "È possibile utilizzare solo 1 immagine di riferimento alla volta con Flux Kontext",
|
||||
"fluxKontextMultipleReferenceImages": "È possibile utilizzare solo 1 immagine di riferimento alla volta con FLUX Kontext tramite BFL API",
|
||||
"promptExpansionResultPending": "Accetta o ignora il risultato dell'espansione del prompt",
|
||||
"promptExpansionPending": "Espansione del prompt in corso"
|
||||
},
|
||||
@@ -1162,7 +1166,19 @@
|
||||
"unexpectedField_withName": "Campo \"{{name}}\" inaspettato",
|
||||
"missingSourceOrTargetHandle": "Identificatore del nodo sorgente o di destinazione mancante",
|
||||
"layout": {
|
||||
"alignmentDR": "In basso a destra"
|
||||
"alignmentDR": "In basso a destra",
|
||||
"autoLayout": "Schema automatico",
|
||||
"nodeSpacing": "Spaziatura nodi",
|
||||
"layerSpacing": "Spaziatura livelli",
|
||||
"layeringStrategy": "Strategia livelli",
|
||||
"longestPath": "Percorso più lungo",
|
||||
"layoutDirection": "Direzione schema",
|
||||
"layoutDirectionRight": "Orizzontale",
|
||||
"layoutDirectionDown": "Verticale",
|
||||
"alignment": "Allineamento nodi",
|
||||
"alignmentUL": "In alto a sinistra",
|
||||
"alignmentDL": "In basso a sinistra",
|
||||
"alignmentUR": "In alto a destra"
|
||||
}
|
||||
},
|
||||
"boards": {
|
||||
@@ -1240,7 +1256,7 @@
|
||||
"batchQueuedDesc_other": "Aggiunte {{count}} sessioni a {{direction}} della coda",
|
||||
"graphQueued": "Grafico in coda",
|
||||
"batch": "Lotto",
|
||||
"clearQueueAlertDialog": "Lo svuotamento della coda annulla immediatamente tutti gli elementi in elaborazione e cancella completamente la coda. I filtri in sospeso verranno annullati.",
|
||||
"clearQueueAlertDialog": "La cancellazione della coda annulla immediatamente tutti gli elementi in elaborazione e cancella completamente la coda. I filtri in sospeso verranno annullati e l'area di lavoro della Tela verrà reimpostata.",
|
||||
"pending": "In attesa",
|
||||
"completedIn": "Completato in",
|
||||
"resumeFailed": "Problema nel riavvio dell'elaborazione",
|
||||
@@ -1296,7 +1312,8 @@
|
||||
"retrySucceeded": "Elemento rieseguito",
|
||||
"retryItem": "Riesegui elemento",
|
||||
"retryFailed": "Problema riesecuzione elemento",
|
||||
"credits": "Crediti"
|
||||
"credits": "Crediti",
|
||||
"cancelAllExceptCurrent": "Annulla tutto tranne quello corrente"
|
||||
},
|
||||
"models": {
|
||||
"noMatchingModels": "Nessun modello corrispondente",
|
||||
@@ -1711,7 +1728,7 @@
|
||||
"structure": {
|
||||
"heading": "Struttura",
|
||||
"paragraphs": [
|
||||
"La struttura determina quanto l'immagine finale rispecchierà il layout dell'originale. Una struttura bassa permette cambiamenti significativi, mentre una struttura alta conserva la composizione e il layout originali."
|
||||
"La struttura determina quanto l'immagine finale rispecchierà il layout dell'originale. Un valore struttura basso permette cambiamenti significativi, mentre un valore struttura alto conserva la composizione e lo schema originali."
|
||||
]
|
||||
},
|
||||
"fluxDevLicense": {
|
||||
@@ -1877,7 +1894,7 @@
|
||||
"opened": "Aperto",
|
||||
"convertGraph": "Converti grafico",
|
||||
"loadWorkflow": "$t(common.load) Flusso di lavoro",
|
||||
"autoLayout": "Disposizione automatica",
|
||||
"autoLayout": "Schema automatico",
|
||||
"loadFromGraph": "Carica il flusso di lavoro dal grafico",
|
||||
"userWorkflows": "Flussi di lavoro utente",
|
||||
"projectWorkflows": "Flussi di lavoro del progetto",
|
||||
@@ -2631,9 +2648,10 @@
|
||||
"watchRecentReleaseVideos": "Guarda i video su questa versione",
|
||||
"watchUiUpdatesOverview": "Guarda le novità dell'interfaccia",
|
||||
"items": [
|
||||
"Genera immagini più velocemente con le nuove Rampe di lancio e una scheda Genera semplificata.",
|
||||
"Modifica con prompt utilizzando Flux Kontext Dev.",
|
||||
"Esporta in PSD, nascondi sovrapposizioni in blocco, organizza modelli e immagini: il tutto in un'interfaccia riprogettata e pensata per il controllo."
|
||||
"Nuova impostazione per inviare tutte le generazioni della Tela direttamente alla Galleria.",
|
||||
"Nuove funzionalità Inverti maschera (Maiusc+V) e Adatta il Riquadro di delimitazione alla maschera (Maiusc+B).",
|
||||
"Supporto esteso per miniature e configurazioni dei modelli.",
|
||||
"Vari altri aggiornamenti e correzioni per la qualità della vita"
|
||||
]
|
||||
},
|
||||
"system": {
|
||||
|
||||
@@ -299,7 +299,7 @@
|
||||
"pruneTooltip": "Cắt bớt {{item_count}} mục đã hoàn tất",
|
||||
"pruneSucceeded": "Đã cắt bớt {{item_count}} mục đã hoàn tất khỏi hàng",
|
||||
"clearTooltip": "Huỷ Và Dọn Dẹp Tất Cả Mục",
|
||||
"clearQueueAlertDialog": "Dọn dẹp hàng đợi sẽ ngay lập tức huỷ tất cả mục đang xử lý và làm sạch hàng hoàn toàn. Bộ lọc đang chờ xử lý sẽ bị huỷ bỏ.",
|
||||
"clearQueueAlertDialog": "Dọn dẹp hàng đợi sẽ ngay lập tức huỷ tất cả mục đang xử lý và làm sạch hàng hoàn toàn. Bộ lọc đang chờ xử lý sẽ bị huỷ bỏ và Vùng Dựng Canva sẽ được khởi động lại.",
|
||||
"session": "Phiên",
|
||||
"item": "Mục",
|
||||
"resumeFailed": "Có Vấn Đề Khi Tiếp Tục Bộ Xử Lý",
|
||||
@@ -343,13 +343,14 @@
|
||||
"retrySucceeded": "Mục Đã Thử Lại",
|
||||
"retryFailed": "Có Vấn Đề Khi Thử Lại Mục",
|
||||
"retryItem": "Thử Lại Mục",
|
||||
"credits": "Nguồn"
|
||||
"credits": "Nguồn",
|
||||
"cancelAllExceptCurrent": "Huỷ Bỏ Tất Cả Ngoại Trừ Mục Hiện Tại"
|
||||
},
|
||||
"hotkeys": {
|
||||
"canvas": {
|
||||
"fitLayersToCanvas": {
|
||||
"title": "Xếp Vừa Layers Vào Canvas",
|
||||
"desc": "Căn chỉnh để góc nhìn vừa vặn với tất cả layer."
|
||||
"desc": "Căn chỉnh để góc nhìn vừa vặn với tất cả layer nhìn thấy dược."
|
||||
},
|
||||
"setZoomTo800Percent": {
|
||||
"desc": "Phóng to canvas lên 800%.",
|
||||
@@ -473,6 +474,24 @@
|
||||
"toggleNonRasterLayers": {
|
||||
"title": "Bật/Tắt Layer Không Thuộc Dạng Raster",
|
||||
"desc": "Hiện hoặc ẩn tất cả layer không thuộc dạng raster (Layer Điều Khiển Được, Lớp Phủ Inpaint, Chỉ Dẫn Khu Vực)."
|
||||
},
|
||||
"invertMask": {
|
||||
"title": "Đảo Ngược Lớp Phủ",
|
||||
"desc": "Đảo ngược lớp phủ inpaint được chọn, tạo một lớp phủ mới với độ trong suốt đối nghịch."
|
||||
},
|
||||
"fitBboxToMasks": {
|
||||
"title": "Xếp Vừa Hộp Giới Hạn Vào Lớp Phủ",
|
||||
"desc": "Tự động điểu chỉnh hộp giới hạn tạo sinh vừa vặn vào lớp phủ inpaint nhìn thấy được"
|
||||
},
|
||||
"applySegmentAnything": {
|
||||
"title": "Áp Dụng Segment Anything",
|
||||
"desc": "Áp dụng lớp phủ Segment Anything hiện tại.",
|
||||
"key": "enter"
|
||||
},
|
||||
"cancelSegmentAnything": {
|
||||
"title": "Huỷ Segment Anything",
|
||||
"desc": "Huỷ hoạt động Segment Anything hiện tại.",
|
||||
"key": "esc"
|
||||
}
|
||||
},
|
||||
"workflows": {
|
||||
@@ -602,6 +621,10 @@
|
||||
"clearSelection": {
|
||||
"desc": "Xoá phần lựa chọn hiện tại nếu có.",
|
||||
"title": "Xoá Phần Lựa Chọn"
|
||||
},
|
||||
"starImage": {
|
||||
"title": "Dấu/Huỷ Sao Hình Ảnh",
|
||||
"desc": "Đánh dấu sao hoặc huỷ đánh dấu sao ảnh được chọn."
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
@@ -661,6 +684,11 @@
|
||||
"selectModelsTab": {
|
||||
"desc": "Chọn tab Model (Mô Hình).",
|
||||
"title": "Chọn Tab Model"
|
||||
},
|
||||
"selectGenerateTab": {
|
||||
"title": "Chọn Tab Tạo Sinh",
|
||||
"desc": "Chọn tab Tạo Sinh.",
|
||||
"key": "1"
|
||||
}
|
||||
},
|
||||
"searchHotkeys": "Tìm Phím tắt",
|
||||
@@ -1090,7 +1118,23 @@
|
||||
"unknownField_withName": "Vùng Dữ Liệu Không Rõ \"{{name}}\"",
|
||||
"unexpectedField_withName": "Sai Vùng Dữ Liệu \"{{name}}\"",
|
||||
"unknownFieldEditWorkflowToFix_withName": "Workflow chứa vùng dữ liệu không rõ \"{{name}}\".\nHãy biên tập workflow để sửa lỗi.",
|
||||
"missingField_withName": "Thiếu Vùng Dữ Liệu \"{{name}}\""
|
||||
"missingField_withName": "Thiếu Vùng Dữ Liệu \"{{name}}\"",
|
||||
"layout": {
|
||||
"autoLayout": "Bố Cục Tự Động",
|
||||
"layeringStrategy": "Chiến Lược Phân Layer",
|
||||
"networkSimplex": "Network Simplex",
|
||||
"longestPath": "Đường Đi Dài Nhất",
|
||||
"nodeSpacing": "Khoảng Cách Node",
|
||||
"layerSpacing": "Khoảng Cách Layer",
|
||||
"layoutDirection": "Hướng Bố Cục",
|
||||
"layoutDirectionRight": "Phải",
|
||||
"layoutDirectionDown": "Xuống",
|
||||
"alignment": "Căn Chỉnh Node",
|
||||
"alignmentUL": "Trên Cùng Bên Trái",
|
||||
"alignmentDL": "Dưới Cùng Bên Trái",
|
||||
"alignmentUR": "Trên Cùng Bên Phải",
|
||||
"alignmentDR": "Dưới Cùng Bên Phải"
|
||||
}
|
||||
},
|
||||
"popovers": {
|
||||
"paramCFGRescaleMultiplier": {
|
||||
@@ -1597,7 +1641,7 @@
|
||||
"modelIncompatibleScaledBboxHeight": "Chiều dài hộp giới hạn theo tỉ lệ là {{height}} nhưng {{model}} yêu cầu bội số của {{multiple}}",
|
||||
"modelIncompatibleScaledBboxWidth": "Chiều rộng hộp giới hạn theo tỉ lệ là {{width}} nhưng {{model}} yêu cầu bội số của {{multiple}}",
|
||||
"modelDisabledForTrial": "Tạo sinh với {{modelName}} là không thể với tài khoản trial. Vào phần thiết lập tài khoản để nâng cấp.",
|
||||
"fluxKontextMultipleReferenceImages": "Chỉ có thể dùng 1 Ảnh Mẫu cùng lúc với Flux Kontext",
|
||||
"fluxKontextMultipleReferenceImages": "Chỉ có thể dùng 1 Ảnh Mẫu cùng lúc với LUX Kontext thông qua BFL API",
|
||||
"promptExpansionPending": "Trong quá trình mở rộng lệnh",
|
||||
"promptExpansionResultPending": "Hãy chấp thuận hoặc huỷ bỏ kết quả mở rộng lệnh của bạn"
|
||||
},
|
||||
@@ -2192,7 +2236,9 @@
|
||||
"off": "Tắt",
|
||||
"switchOnStart": "Khi Bắt Đầu",
|
||||
"switchOnFinish": "Khi Kết Thúc"
|
||||
}
|
||||
},
|
||||
"fitBboxToMasks": "Xếp Vừa Hộp Giới Hạn Vào Lớp Phủ",
|
||||
"invertMask": "Đảo Ngược Lớp Phủ"
|
||||
},
|
||||
"stylePresets": {
|
||||
"negativePrompt": "Lệnh Tiêu Cực",
|
||||
@@ -2354,7 +2400,15 @@
|
||||
"noValidLayerAdapters": "Không có Layer Adaper Phù Hợp",
|
||||
"promptGenerationStarted": "Trình tạo sinh lệnh khởi động",
|
||||
"uploadAndPromptGenerationFailed": "Thất bại khi tải lên ảnh để tạo sinh lệnh",
|
||||
"promptExpansionFailed": "Có vấn đề xảy ra. Hãy thử mở rộng lệnh lại."
|
||||
"promptExpansionFailed": "Có vấn đề xảy ra. Hãy thử mở rộng lệnh lại.",
|
||||
"maskInverted": "Đã Đảo Ngược Lớp Phủ",
|
||||
"maskInvertFailed": "Thất Bại Khi Đảo Ngược Lớp Phủ",
|
||||
"noVisibleMasks": "Không Có Lớp Phủ Đang Hiển Thị",
|
||||
"noVisibleMasksDesc": "Tạo hoặc bật ít nhất một lớp phủ inpaint để đảo ngược",
|
||||
"noInpaintMaskSelected": "Không Có Lớp Phủ Inpant Được Chọn",
|
||||
"noInpaintMaskSelectedDesc": "Chọn một lớp phủ inpaint để đảo ngược",
|
||||
"invalidBbox": "Hộp Giới Hạn Không Hợp Lệ",
|
||||
"invalidBboxDesc": "Hợp giới hạn có kích thước không hợp lệ"
|
||||
},
|
||||
"ui": {
|
||||
"tabs": {
|
||||
@@ -2588,9 +2642,10 @@
|
||||
"watchRecentReleaseVideos": "Xem Video Phát Hành Mới Nhất",
|
||||
"watchUiUpdatesOverview": "Xem Tổng Quan Về Những Cập Nhật Cho Giao Diện Người Dùng",
|
||||
"items": [
|
||||
"Tạo sinh ảnh nhanh hơn với Launchpad và thẻ Tạo Sinh đã cơ bản hoá.",
|
||||
"Biên tập với lệnh bằng Flux Kontext Dev.",
|
||||
"Xuất ra file PSD, ẩn số lượng lớn lớp phủ, sắp xếp model & ảnh — tất cả cho một giao diện đã thiết kế lại để chuyên điều khiển."
|
||||
"Thiết lập mới để gửi các sản phẩm tạo sinh từ Canvas trực tiếp đến Thư Viện Ảnh.",
|
||||
"Chức năng mới Đảo Ngược Lớp Phủ (Shift+V) và khả năng Xếp Vừa Hộp Giới Hạn Vào Lớp Phủ (Shift+B).",
|
||||
"Mở rộng hỗ trợ cho Ảnh Minh Hoạ và thiết lập model.",
|
||||
"Nhiều bản cập nhật và sửa lỗi chất lượng"
|
||||
]
|
||||
},
|
||||
"upsell": {
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useStore } from '@nanostores/react';
|
||||
import { GlobalHookIsolator } from 'app/components/GlobalHookIsolator';
|
||||
import { GlobalModalIsolator } from 'app/components/GlobalModalIsolator';
|
||||
import { $didStudioInit, type StudioInitAction } from 'app/hooks/useStudioInitAction';
|
||||
import { clearStorage } from 'app/store/enhancers/reduxRemember/driver';
|
||||
import type { PartialAppConfig } from 'app/types/invokeai';
|
||||
import Loading from 'common/components/Loading/Loading';
|
||||
import { useClearStorage } from 'common/hooks/useClearStorage';
|
||||
import { AppContent } from 'features/ui/components/AppContent';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
@@ -21,13 +21,12 @@ interface Props {
|
||||
|
||||
const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
|
||||
const didStudioInit = useStore($didStudioInit);
|
||||
const clearStorage = useClearStorage();
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
clearStorage();
|
||||
location.reload();
|
||||
return false;
|
||||
}, [clearStorage]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeLocaleProvider>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { StudioInitAction } from 'app/hooks/useStudioInitAction';
|
||||
import { $didStudioInit } from 'app/hooks/useStudioInitAction';
|
||||
import type { LoggingOverrides } from 'app/logging/logger';
|
||||
import { $loggingOverrides, configureLogging } from 'app/logging/logger';
|
||||
import { addStorageListeners } from 'app/store/enhancers/reduxRemember/driver';
|
||||
import { $accountSettingsLink } from 'app/store/nanostores/accountSettingsLink';
|
||||
import { $authToken } from 'app/store/nanostores/authToken';
|
||||
import { $baseUrl } from 'app/store/nanostores/baseUrl';
|
||||
@@ -35,7 +36,7 @@ import {
|
||||
import type { WorkflowCategory } from 'features/nodes/types/workflow';
|
||||
import type { ToastConfig } from 'features/toast/toast';
|
||||
import type { PropsWithChildren, ReactNode } from 'react';
|
||||
import React, { lazy, memo, useEffect, useLayoutEffect, useMemo } from 'react';
|
||||
import React, { lazy, memo, useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { addMiddleware, resetMiddlewares } from 'redux-dynamic-middlewares';
|
||||
import { $socketOptions } from 'services/events/stores';
|
||||
@@ -70,6 +71,7 @@ interface Props extends PropsWithChildren {
|
||||
* If provided, overrides in-app navigation to the model manager
|
||||
*/
|
||||
onClickGoToModelManager?: () => void;
|
||||
storagePersistThrottle?: number;
|
||||
}
|
||||
|
||||
const InvokeAIUI = ({
|
||||
@@ -96,7 +98,11 @@ const InvokeAIUI = ({
|
||||
loggingOverrides,
|
||||
onClickGoToModelManager,
|
||||
whatsNew,
|
||||
storagePersistThrottle = 2000,
|
||||
}: Props) => {
|
||||
const [store, setStore] = useState<ReturnType<typeof createStore> | undefined>(undefined);
|
||||
const [didRehydrate, setDidRehydrate] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
/*
|
||||
* We need to configure logging before anything else happens - useLayoutEffect ensures we set this at the first
|
||||
@@ -308,22 +314,30 @@ const InvokeAIUI = ({
|
||||
};
|
||||
}, [isDebugging]);
|
||||
|
||||
const store = useMemo(() => {
|
||||
return createStore(projectId);
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const onRehydrated = () => {
|
||||
setDidRehydrate(true);
|
||||
};
|
||||
const store = createStore({ persist: true, persistThrottle: storagePersistThrottle, onRehydrated });
|
||||
setStore(store);
|
||||
$store.set(store);
|
||||
if (import.meta.env.MODE === 'development') {
|
||||
window.$store = $store;
|
||||
}
|
||||
const removeStorageListeners = addStorageListeners();
|
||||
return () => {
|
||||
removeStorageListeners();
|
||||
setStore(undefined);
|
||||
$store.set(undefined);
|
||||
if (import.meta.env.MODE === 'development') {
|
||||
window.$store = undefined;
|
||||
}
|
||||
};
|
||||
}, [store]);
|
||||
}, [storagePersistThrottle]);
|
||||
|
||||
if (!store || !didRehydrate) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -93,5 +93,7 @@ export const configureLogging = (
|
||||
localStorage.setItem('ROARR_FILTER', filter);
|
||||
}
|
||||
|
||||
ROARR.write = createLogWriter();
|
||||
const styleOutput = localStorage.getItem('ROARR_STYLE_OUTPUT') === 'false' ? false : true;
|
||||
|
||||
ROARR.write = createLogWriter({ styleOutput });
|
||||
};
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export const STORAGE_PREFIX = '@@invokeai-';
|
||||
export const EMPTY_ARRAY = [];
|
||||
export const EMPTY_OBJECT = {};
|
||||
|
||||
@@ -1,40 +1,209 @@
|
||||
import { logger } from 'app/logging/logger';
|
||||
import { StorageError } from 'app/store/enhancers/reduxRemember/errors';
|
||||
import { $authToken } from 'app/store/nanostores/authToken';
|
||||
import { $projectId } from 'app/store/nanostores/projectId';
|
||||
import { $queueId } from 'app/store/nanostores/queueId';
|
||||
import type { UseStore } from 'idb-keyval';
|
||||
import { clear, createStore as createIDBKeyValStore, get, set } from 'idb-keyval';
|
||||
import { atom } from 'nanostores';
|
||||
import { createStore as idbCreateStore, del as idbDel, get as idbGet } from 'idb-keyval';
|
||||
import type { Driver } from 'redux-remember';
|
||||
import { serializeError } from 'serialize-error';
|
||||
import { buildV1Url, getBaseUrl } from 'services/api';
|
||||
import type { JsonObject } from 'type-fest';
|
||||
|
||||
// Create a custom idb-keyval store (just needed to customize the name)
|
||||
const $idbKeyValStore = atom<UseStore>(createIDBKeyValStore('invoke', 'invoke-store'));
|
||||
const log = logger('system');
|
||||
|
||||
export const clearIdbKeyValStore = () => {
|
||||
clear($idbKeyValStore.get());
|
||||
const getUrl = (endpoint: 'get_by_key' | 'set_by_key' | 'delete', key?: string) => {
|
||||
const baseUrl = getBaseUrl();
|
||||
const query: Record<string, string> = {};
|
||||
if (key) {
|
||||
query['key'] = key;
|
||||
}
|
||||
|
||||
const path = buildV1Url(`client_state/${$queueId.get()}/${endpoint}`, query);
|
||||
const url = `${baseUrl}/${path}`;
|
||||
return url;
|
||||
};
|
||||
|
||||
// Create redux-remember driver, wrapping idb-keyval
|
||||
export const idbKeyValDriver: Driver = {
|
||||
getItem: (key) => {
|
||||
try {
|
||||
return get(key, $idbKeyValStore.get());
|
||||
} catch (originalError) {
|
||||
throw new StorageError({
|
||||
key,
|
||||
projectId: $projectId.get(),
|
||||
originalError,
|
||||
});
|
||||
}
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
try {
|
||||
return set(key, value, $idbKeyValStore.get());
|
||||
} catch (originalError) {
|
||||
throw new StorageError({
|
||||
key,
|
||||
value,
|
||||
projectId: $projectId.get(),
|
||||
originalError,
|
||||
});
|
||||
}
|
||||
},
|
||||
const getHeaders = () => {
|
||||
const headers = new Headers();
|
||||
const authToken = $authToken.get();
|
||||
const projectId = $projectId.get();
|
||||
if (authToken) {
|
||||
headers.set('Authorization', `Bearer ${authToken}`);
|
||||
}
|
||||
if (projectId) {
|
||||
headers.set('project-id', projectId);
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
// Persistence happens per slice. To track when persistence is in progress, maintain a ref count, incrementing
|
||||
// it when a slice is being persisted and decrementing it when the persistence is done.
|
||||
let persistRefCount = 0;
|
||||
|
||||
// Keep track of the last persisted state for each key to avoid unnecessary network requests.
|
||||
//
|
||||
// `redux-remember` persists individual slices of state, so we can implicity denylist a slice by not giving it a
|
||||
// persist config.
|
||||
//
|
||||
// However, we may need to avoid persisting individual _fields_ of a slice. `redux-remember` does not provide a
|
||||
// way to do this directly.
|
||||
//
|
||||
// To accomplish this, we add a layer of logic on top of the `redux-remember`. In the state serializer function
|
||||
// provided to `redux-remember`, we can omit certain fields from the state that we do not want to persist. See
|
||||
// the implementation in `store.ts` for this logic.
|
||||
//
|
||||
// This logic is unknown to `redux-remember`. When an omitted field changes, it will still attempt to persist the
|
||||
// whole slice, even if the final, _serialized_ slice value is unchanged.
|
||||
//
|
||||
// To avoid unnecessary network requests, we keep track of the last persisted state for each key in this map.
|
||||
// If the value to be persisted is the same as the last persisted value, we will skip the network request.
|
||||
const lastPersistedState = new Map<string, string | undefined>();
|
||||
|
||||
// As of v6.3.0, we use server-backed storage for client state. This replaces the previous IndexedDB-based storage,
|
||||
// which was implemented using `idb-keyval`.
|
||||
//
|
||||
// To facilitate a smooth transition, we implement a migration strategy that attempts to retrieve values from IndexedDB
|
||||
// and persist them to the new server-backed storage. This is done on a best-effort basis.
|
||||
|
||||
// These constants were used in the previous IndexedDB-based storage implementation.
|
||||
const IDB_DB_NAME = 'invoke';
|
||||
const IDB_STORE_NAME = 'invoke-store';
|
||||
const IDB_STORAGE_PREFIX = '@@invokeai-';
|
||||
|
||||
// Lazy store creation
|
||||
let _idbKeyValStore: UseStore | null = null;
|
||||
const getIdbKeyValStore = () => {
|
||||
if (_idbKeyValStore === null) {
|
||||
_idbKeyValStore = idbCreateStore(IDB_DB_NAME, IDB_STORE_NAME);
|
||||
}
|
||||
return _idbKeyValStore;
|
||||
};
|
||||
|
||||
const getIdbKey = (key: string) => {
|
||||
return `${IDB_STORAGE_PREFIX}${key}`;
|
||||
};
|
||||
|
||||
const getItem = async (key: string) => {
|
||||
try {
|
||||
const url = getUrl('get_by_key', key);
|
||||
const headers = getHeaders();
|
||||
const res = await fetch(url, { method: 'GET', headers });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Response status: ${res.status}`);
|
||||
}
|
||||
const value = await res.json();
|
||||
|
||||
// Best-effort migration from IndexedDB to the new storage system
|
||||
log.trace({ key, value }, 'Server-backed storage value retrieved');
|
||||
|
||||
if (!value) {
|
||||
const idbKey = getIdbKey(key);
|
||||
try {
|
||||
// It's a bit tricky to query IndexedDB directly to check if value exists, so we use `idb-keyval` to do it.
|
||||
// Thing is, `idb-keyval` requires you to create a store to query it. End result - we are creating a store
|
||||
// even if we don't use it for anything besides checking if the key is present.
|
||||
const idbKeyValStore = getIdbKeyValStore();
|
||||
const idbValue = await idbGet(idbKey, idbKeyValStore);
|
||||
if (idbValue) {
|
||||
log.debug(
|
||||
{ key, idbKey, idbValue },
|
||||
'No value in server-backed storage, but found value in IndexedDB - attempting migration'
|
||||
);
|
||||
await idbDel(idbKey, idbKeyValStore);
|
||||
await setItem(key, idbValue);
|
||||
log.debug({ key, idbKey, idbValue }, 'Migration successful');
|
||||
return idbValue;
|
||||
}
|
||||
} catch (error) {
|
||||
// Just log if IndexedDB retrieval fails - this is a best-effort migration.
|
||||
log.debug(
|
||||
{ key, idbKey, error: serializeError(error) } as JsonObject,
|
||||
'Error checking for or migrating from IndexedDB'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lastPersistedState.set(key, value);
|
||||
log.trace({ key, last: lastPersistedState.get(key), next: value }, `Getting state for ${key}`);
|
||||
return value;
|
||||
} catch (originalError) {
|
||||
throw new StorageError({
|
||||
key,
|
||||
projectId: $projectId.get(),
|
||||
originalError,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const setItem = async (key: string, value: string) => {
|
||||
try {
|
||||
persistRefCount++;
|
||||
if (lastPersistedState.get(key) === value) {
|
||||
log.trace(
|
||||
{ key, last: lastPersistedState.get(key), next: value },
|
||||
`Skipping persist for ${key} as value is unchanged`
|
||||
);
|
||||
return value;
|
||||
}
|
||||
log.trace({ key, last: lastPersistedState.get(key), next: value }, `Persisting state for ${key}`);
|
||||
const url = getUrl('set_by_key', key);
|
||||
const headers = getHeaders();
|
||||
const res = await fetch(url, { method: 'POST', headers, body: value });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Response status: ${res.status}`);
|
||||
}
|
||||
const resultValue = await res.json();
|
||||
lastPersistedState.set(key, resultValue);
|
||||
return resultValue;
|
||||
} catch (originalError) {
|
||||
throw new StorageError({
|
||||
key,
|
||||
value,
|
||||
projectId: $projectId.get(),
|
||||
originalError,
|
||||
});
|
||||
} finally {
|
||||
persistRefCount--;
|
||||
if (persistRefCount < 0) {
|
||||
log.trace('Persist ref count is negative, resetting to 0');
|
||||
persistRefCount = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const reduxRememberDriver: Driver = { getItem, setItem };
|
||||
|
||||
export const clearStorage = async () => {
|
||||
try {
|
||||
persistRefCount++;
|
||||
const url = getUrl('delete');
|
||||
const headers = getHeaders();
|
||||
const res = await fetch(url, { method: 'POST', headers });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Response status: ${res.status}`);
|
||||
}
|
||||
} catch {
|
||||
log.error('Failed to reset client state');
|
||||
} finally {
|
||||
persistRefCount--;
|
||||
lastPersistedState.clear();
|
||||
if (persistRefCount < 0) {
|
||||
log.trace('Persist ref count is negative, resetting to 0');
|
||||
persistRefCount = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const addStorageListeners = () => {
|
||||
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (persistRefCount > 0) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
window.addEventListener('beforeunload', onBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', onBeforeUnload);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -33,8 +33,9 @@ export class StorageError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const log = logger('system');
|
||||
|
||||
export const errorHandler = (err: PersistError | RehydrateError) => {
|
||||
const log = logger('system');
|
||||
if (err instanceof PersistError) {
|
||||
log.error({ error: serializeError(err) }, 'Problem persisting state');
|
||||
} else if (err instanceof RehydrateError) {
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { TypedStartListening } from '@reduxjs/toolkit';
|
||||
import { addListener, createListenerMiddleware } from '@reduxjs/toolkit';
|
||||
import { addAdHocPostProcessingRequestedListener } from 'app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener';
|
||||
import { addAnyEnqueuedListener } from 'app/store/middleware/listenerMiddleware/listeners/anyEnqueued';
|
||||
import { addAppConfigReceivedListener } from 'app/store/middleware/listenerMiddleware/listeners/appConfigReceived';
|
||||
import { addAppStartedListener } from 'app/store/middleware/listenerMiddleware/listeners/appStarted';
|
||||
import { addBatchEnqueuedListener } from 'app/store/middleware/listenerMiddleware/listeners/batchEnqueued';
|
||||
import { addDeleteBoardAndImagesFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted';
|
||||
import { addBoardIdSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/boardIdSelected';
|
||||
import { addBulkDownloadListeners } from 'app/store/middleware/listenerMiddleware/listeners/bulkDownload';
|
||||
import { addGetOpenAPISchemaListener } from 'app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema';
|
||||
import { addImageAddedToBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard';
|
||||
import { addImageRemovedFromBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard';
|
||||
import { addImageUploadedFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageUploaded';
|
||||
import { addModelSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/modelSelected';
|
||||
import { addModelsLoadedListener } from 'app/store/middleware/listenerMiddleware/listeners/modelsLoaded';
|
||||
import { addSetDefaultSettingsListener } from 'app/store/middleware/listenerMiddleware/listeners/setDefaultSettings';
|
||||
import { addSocketConnectedEventListener } from 'app/store/middleware/listenerMiddleware/listeners/socketConnected';
|
||||
import type { AppDispatch, RootState } from 'app/store/store';
|
||||
|
||||
import { addArchivedOrDeletedBoardListener } from './listeners/addArchivedOrDeletedBoardListener';
|
||||
|
||||
export const listenerMiddleware = createListenerMiddleware();
|
||||
|
||||
export type AppStartListening = TypedStartListening<RootState, AppDispatch>;
|
||||
|
||||
const startAppListening = listenerMiddleware.startListening as AppStartListening;
|
||||
|
||||
export const addAppListener = addListener.withTypes<RootState, AppDispatch>();
|
||||
|
||||
/**
|
||||
* The RTK listener middleware is a lightweight alternative sagas/observables.
|
||||
*
|
||||
* Most side effect logic should live in a listener.
|
||||
*/
|
||||
|
||||
// Image uploaded
|
||||
addImageUploadedFulfilledListener(startAppListening);
|
||||
|
||||
// Image deleted
|
||||
addDeleteBoardAndImagesFulfilledListener(startAppListening);
|
||||
|
||||
// User Invoked
|
||||
addAnyEnqueuedListener(startAppListening);
|
||||
addBatchEnqueuedListener(startAppListening);
|
||||
|
||||
// Socket.IO
|
||||
addSocketConnectedEventListener(startAppListening);
|
||||
|
||||
// Gallery bulk download
|
||||
addBulkDownloadListeners(startAppListening);
|
||||
|
||||
// Boards
|
||||
addImageAddedToBoardFulfilledListener(startAppListening);
|
||||
addImageRemovedFromBoardFulfilledListener(startAppListening);
|
||||
addBoardIdSelectedListener(startAppListening);
|
||||
addArchivedOrDeletedBoardListener(startAppListening);
|
||||
|
||||
// Node schemas
|
||||
addGetOpenAPISchemaListener(startAppListening);
|
||||
|
||||
// Models
|
||||
addModelSelectedListener(startAppListening);
|
||||
|
||||
// app startup
|
||||
addAppStartedListener(startAppListening);
|
||||
addModelsLoadedListener(startAppListening);
|
||||
addAppConfigReceivedListener(startAppListening);
|
||||
|
||||
// Ad-hoc upscale workflwo
|
||||
addAdHocPostProcessingRequestedListener(startAppListening);
|
||||
|
||||
addSetDefaultSettingsListener(startAppListening);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
import { logger } from 'app/logging/logger';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import type { AppStartListening } from 'app/store/store';
|
||||
import { buildAdHocPostProcessingGraph } from 'features/nodes/util/graph/buildAdHocPostProcessingGraph';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { t } from 'i18next';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isAnyOf } from '@reduxjs/toolkit';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import type { AppStartListening } from 'app/store/store';
|
||||
import { selectListBoardsQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||
import {
|
||||
autoAddBoardIdChanged,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import type { AppStartListening } from 'app/store/store';
|
||||
import { queueApi, selectQueueStatus } from 'services/api/endpoints/queue';
|
||||
|
||||
export const addAnyEnqueuedListener = (startAppListening: AppStartListening) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import type { AppStartListening } from 'app/store/store';
|
||||
import { setInfillMethod } from 'features/controlLayers/store/paramsSlice';
|
||||
import { shouldUseNSFWCheckerChanged, shouldUseWatermarkerChanged } from 'features/system/store/systemSlice';
|
||||
import { appInfoApi } from 'services/api/endpoints/appInfo';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import type { AppStartListening } from 'app/store/store';
|
||||
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
|
||||
import { imageSelected } from 'features/gallery/store/gallerySlice';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { logger } from 'app/logging/logger';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import type { AppStartListening } from 'app/store/store';
|
||||
import { truncate } from 'es-toolkit/compat';
|
||||
import { zPydanticValidationError } from 'features/system/store/zodSchemas';
|
||||
import { toast } from 'features/toast/toast';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import type { AppStartListening } from 'app/store/store';
|
||||
import { selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice';
|
||||
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
|
||||
import { getImageUsage } from 'features/deleteImageModal/store/state';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isAnyOf } from '@reduxjs/toolkit';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import type { AppStartListening } from 'app/store/store';
|
||||
import { selectGetImageNamesQueryArgs, selectSelectedBoardId } from 'features/gallery/store/gallerySelectors';
|
||||
import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { logger } from 'app/logging/logger';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import type { AppStartListening } from 'app/store/store';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { t } from 'i18next';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { logger } from 'app/logging/logger';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import type { AppStartListening } from 'app/store/store';
|
||||
import { parseify } from 'common/util/serialize';
|
||||
import { size } from 'es-toolkit/compat';
|
||||
import { $templates } from 'features/nodes/store/nodesSlice';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { logger } from 'app/logging/logger';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import type { AppStartListening } from 'app/store/store';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
|
||||
const log = logger('gallery');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { logger } from 'app/logging/logger';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import type { AppStartListening } from 'app/store/store';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
|
||||
const log = logger('gallery');
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { isAnyOf } from '@reduxjs/toolkit';
|
||||
import { logger } from 'app/logging/logger';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import type { RootState } from 'app/store/store';
|
||||
import type { AppStartListening, RootState } from 'app/store/store';
|
||||
import { omit } from 'es-toolkit/compat';
|
||||
import { imageUploadedClientSide } from 'features/gallery/store/actions';
|
||||
import { selectListBoardsQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { logger } from 'app/logging/logger';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import type { AppStartListening } from 'app/store/store';
|
||||
import { bboxSyncedToOptimalDimension, rgRefImageModelChanged } from 'features/controlLayers/store/canvasSlice';
|
||||
import { buildSelectIsStaging, selectCanvasSessionId } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { loraDeleted } from 'features/controlLayers/store/lorasSlice';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { logger } from 'app/logging/logger';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import type { AppDispatch, RootState } from 'app/store/store';
|
||||
import type { AppDispatch, AppStartListening, RootState } from 'app/store/store';
|
||||
import { controlLayerModelChanged, rgRefImageModelChanged } from 'features/controlLayers/store/canvasSlice';
|
||||
import { loraDeleted } from 'features/controlLayers/store/lorasSlice';
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import type { AppStartListening } from 'app/store/store';
|
||||
import { isNil } from 'es-toolkit';
|
||||
import { bboxHeightChanged, bboxWidthChanged } from 'features/controlLayers/store/canvasSlice';
|
||||
import { buildSelectIsStaging, selectCanvasSessionId } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { objectEquals } from '@observ33r/object-equals';
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
import { logger } from 'app/logging/logger';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import { $baseUrl } from 'app/store/nanostores/baseUrl';
|
||||
import type { AppStartListening } from 'app/store/store';
|
||||
import { atom } from 'nanostores';
|
||||
import { api } from 'services/api';
|
||||
import { modelsApi } from 'services/api/endpoints/models';
|
||||
|
||||
@@ -1,159 +1,165 @@
|
||||
import type { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit';
|
||||
import { autoBatchEnhancer, combineReducers, configureStore } from '@reduxjs/toolkit';
|
||||
import type { ThunkDispatch, TypedStartListening, UnknownAction } from '@reduxjs/toolkit';
|
||||
import { addListener, combineReducers, configureStore, createAction, createListenerMiddleware } from '@reduxjs/toolkit';
|
||||
import { logger } from 'app/logging/logger';
|
||||
import { idbKeyValDriver } from 'app/store/enhancers/reduxRemember/driver';
|
||||
import { errorHandler } from 'app/store/enhancers/reduxRemember/errors';
|
||||
import { addAdHocPostProcessingRequestedListener } from 'app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener';
|
||||
import { addAnyEnqueuedListener } from 'app/store/middleware/listenerMiddleware/listeners/anyEnqueued';
|
||||
import { addAppConfigReceivedListener } from 'app/store/middleware/listenerMiddleware/listeners/appConfigReceived';
|
||||
import { addAppStartedListener } from 'app/store/middleware/listenerMiddleware/listeners/appStarted';
|
||||
import { addBatchEnqueuedListener } from 'app/store/middleware/listenerMiddleware/listeners/batchEnqueued';
|
||||
import { addDeleteBoardAndImagesFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted';
|
||||
import { addBoardIdSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/boardIdSelected';
|
||||
import { addBulkDownloadListeners } from 'app/store/middleware/listenerMiddleware/listeners/bulkDownload';
|
||||
import { addGetOpenAPISchemaListener } from 'app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema';
|
||||
import { addImageAddedToBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard';
|
||||
import { addImageRemovedFromBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard';
|
||||
import { addModelSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/modelSelected';
|
||||
import { addModelsLoadedListener } from 'app/store/middleware/listenerMiddleware/listeners/modelsLoaded';
|
||||
import { addSetDefaultSettingsListener } from 'app/store/middleware/listenerMiddleware/listeners/setDefaultSettings';
|
||||
import { addSocketConnectedEventListener } from 'app/store/middleware/listenerMiddleware/listeners/socketConnected';
|
||||
import { deepClone } from 'common/util/deepClone';
|
||||
import { keys, mergeWith, omit, pick } from 'es-toolkit/compat';
|
||||
import { changeBoardModalSlice } from 'features/changeBoardModal/store/slice';
|
||||
import { canvasSettingsPersistConfig, canvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice';
|
||||
import { canvasPersistConfig, canvasSlice, canvasUndoableConfig } from 'features/controlLayers/store/canvasSlice';
|
||||
import {
|
||||
canvasSessionSlice,
|
||||
canvasStagingAreaPersistConfig,
|
||||
} from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { lorasPersistConfig, lorasSlice } from 'features/controlLayers/store/lorasSlice';
|
||||
import { paramsPersistConfig, paramsSlice } from 'features/controlLayers/store/paramsSlice';
|
||||
import { refImagesPersistConfig, refImagesSlice } from 'features/controlLayers/store/refImagesSlice';
|
||||
import { dynamicPromptsPersistConfig, dynamicPromptsSlice } from 'features/dynamicPrompts/store/dynamicPromptsSlice';
|
||||
import { galleryPersistConfig, gallerySlice } from 'features/gallery/store/gallerySlice';
|
||||
import { modelManagerV2PersistConfig, modelManagerV2Slice } from 'features/modelManagerV2/store/modelManagerV2Slice';
|
||||
import { nodesPersistConfig, nodesSlice, nodesUndoableConfig } from 'features/nodes/store/nodesSlice';
|
||||
import { workflowLibraryPersistConfig, workflowLibrarySlice } from 'features/nodes/store/workflowLibrarySlice';
|
||||
import { workflowSettingsPersistConfig, workflowSettingsSlice } from 'features/nodes/store/workflowSettingsSlice';
|
||||
import { upscalePersistConfig, upscaleSlice } from 'features/parameters/store/upscaleSlice';
|
||||
import { queueSlice } from 'features/queue/store/queueSlice';
|
||||
import { stylePresetPersistConfig, stylePresetSlice } from 'features/stylePresets/store/stylePresetSlice';
|
||||
import { configSlice } from 'features/system/store/configSlice';
|
||||
import { systemPersistConfig, systemSlice } from 'features/system/store/systemSlice';
|
||||
import { uiPersistConfig, uiSlice } from 'features/ui/store/uiSlice';
|
||||
import { changeBoardModalSliceConfig } from 'features/changeBoardModal/store/slice';
|
||||
import { canvasSettingsSliceConfig } from 'features/controlLayers/store/canvasSettingsSlice';
|
||||
import { canvasSliceConfig } from 'features/controlLayers/store/canvasSlice';
|
||||
import { canvasSessionSliceConfig } from 'features/controlLayers/store/canvasStagingAreaSlice';
|
||||
import { lorasSliceConfig } from 'features/controlLayers/store/lorasSlice';
|
||||
import { paramsSliceConfig } from 'features/controlLayers/store/paramsSlice';
|
||||
import { refImagesSliceConfig } from 'features/controlLayers/store/refImagesSlice';
|
||||
import { dynamicPromptsSliceConfig } from 'features/dynamicPrompts/store/dynamicPromptsSlice';
|
||||
import { gallerySliceConfig } from 'features/gallery/store/gallerySlice';
|
||||
import { modelManagerSliceConfig } from 'features/modelManagerV2/store/modelManagerV2Slice';
|
||||
import { nodesSliceConfig } from 'features/nodes/store/nodesSlice';
|
||||
import { workflowLibrarySliceConfig } from 'features/nodes/store/workflowLibrarySlice';
|
||||
import { workflowSettingsSliceConfig } from 'features/nodes/store/workflowSettingsSlice';
|
||||
import { upscaleSliceConfig } from 'features/parameters/store/upscaleSlice';
|
||||
import { queueSliceConfig } from 'features/queue/store/queueSlice';
|
||||
import { stylePresetSliceConfig } from 'features/stylePresets/store/stylePresetSlice';
|
||||
import { configSliceConfig } from 'features/system/store/configSlice';
|
||||
import { systemSliceConfig } from 'features/system/store/systemSlice';
|
||||
import { uiSliceConfig } from 'features/ui/store/uiSlice';
|
||||
import { diff } from 'jsondiffpatch';
|
||||
import dynamicMiddlewares from 'redux-dynamic-middlewares';
|
||||
import type { SerializeFunction, UnserializeFunction } from 'redux-remember';
|
||||
import { rememberEnhancer, rememberReducer } from 'redux-remember';
|
||||
import { REMEMBER_REHYDRATED, rememberEnhancer, rememberReducer } from 'redux-remember';
|
||||
import undoable, { newHistory } from 'redux-undo';
|
||||
import { serializeError } from 'serialize-error';
|
||||
import { api } from 'services/api';
|
||||
import { authToastMiddleware } from 'services/api/authToastMiddleware';
|
||||
import type { JsonObject } from 'type-fest';
|
||||
|
||||
import { STORAGE_PREFIX } from './constants';
|
||||
import { reduxRememberDriver } from './enhancers/reduxRemember/driver';
|
||||
import { actionSanitizer } from './middleware/devtools/actionSanitizer';
|
||||
import { actionsDenylist } from './middleware/devtools/actionsDenylist';
|
||||
import { stateSanitizer } from './middleware/devtools/stateSanitizer';
|
||||
import { listenerMiddleware } from './middleware/listenerMiddleware';
|
||||
import { addArchivedOrDeletedBoardListener } from './middleware/listenerMiddleware/listeners/addArchivedOrDeletedBoardListener';
|
||||
import { addImageUploadedFulfilledListener } from './middleware/listenerMiddleware/listeners/imageUploaded';
|
||||
|
||||
export const listenerMiddleware = createListenerMiddleware();
|
||||
|
||||
const log = logger('system');
|
||||
|
||||
const allReducers = {
|
||||
[api.reducerPath]: api.reducer,
|
||||
[gallerySlice.name]: gallerySlice.reducer,
|
||||
[nodesSlice.name]: undoable(nodesSlice.reducer, nodesUndoableConfig),
|
||||
[systemSlice.name]: systemSlice.reducer,
|
||||
[configSlice.name]: configSlice.reducer,
|
||||
[uiSlice.name]: uiSlice.reducer,
|
||||
[dynamicPromptsSlice.name]: dynamicPromptsSlice.reducer,
|
||||
[changeBoardModalSlice.name]: changeBoardModalSlice.reducer,
|
||||
[modelManagerV2Slice.name]: modelManagerV2Slice.reducer,
|
||||
[queueSlice.name]: queueSlice.reducer,
|
||||
[canvasSlice.name]: undoable(canvasSlice.reducer, canvasUndoableConfig),
|
||||
[workflowSettingsSlice.name]: workflowSettingsSlice.reducer,
|
||||
[upscaleSlice.name]: upscaleSlice.reducer,
|
||||
[stylePresetSlice.name]: stylePresetSlice.reducer,
|
||||
[paramsSlice.name]: paramsSlice.reducer,
|
||||
[canvasSettingsSlice.name]: canvasSettingsSlice.reducer,
|
||||
[canvasSessionSlice.name]: canvasSessionSlice.reducer,
|
||||
[lorasSlice.name]: lorasSlice.reducer,
|
||||
[workflowLibrarySlice.name]: workflowLibrarySlice.reducer,
|
||||
[refImagesSlice.name]: refImagesSlice.reducer,
|
||||
// When adding a slice, add the config to the SLICE_CONFIGS object below, then add the reducer to ALL_REDUCERS.
|
||||
const SLICE_CONFIGS = {
|
||||
[canvasSessionSliceConfig.slice.reducerPath]: canvasSessionSliceConfig,
|
||||
[canvasSettingsSliceConfig.slice.reducerPath]: canvasSettingsSliceConfig,
|
||||
[canvasSliceConfig.slice.reducerPath]: canvasSliceConfig,
|
||||
[changeBoardModalSliceConfig.slice.reducerPath]: changeBoardModalSliceConfig,
|
||||
[configSliceConfig.slice.reducerPath]: configSliceConfig,
|
||||
[dynamicPromptsSliceConfig.slice.reducerPath]: dynamicPromptsSliceConfig,
|
||||
[gallerySliceConfig.slice.reducerPath]: gallerySliceConfig,
|
||||
[lorasSliceConfig.slice.reducerPath]: lorasSliceConfig,
|
||||
[modelManagerSliceConfig.slice.reducerPath]: modelManagerSliceConfig,
|
||||
[nodesSliceConfig.slice.reducerPath]: nodesSliceConfig,
|
||||
[paramsSliceConfig.slice.reducerPath]: paramsSliceConfig,
|
||||
[queueSliceConfig.slice.reducerPath]: queueSliceConfig,
|
||||
[refImagesSliceConfig.slice.reducerPath]: refImagesSliceConfig,
|
||||
[stylePresetSliceConfig.slice.reducerPath]: stylePresetSliceConfig,
|
||||
[systemSliceConfig.slice.reducerPath]: systemSliceConfig,
|
||||
[uiSliceConfig.slice.reducerPath]: uiSliceConfig,
|
||||
[upscaleSliceConfig.slice.reducerPath]: upscaleSliceConfig,
|
||||
[workflowLibrarySliceConfig.slice.reducerPath]: workflowLibrarySliceConfig,
|
||||
[workflowSettingsSliceConfig.slice.reducerPath]: workflowSettingsSliceConfig,
|
||||
};
|
||||
|
||||
const rootReducer = combineReducers(allReducers);
|
||||
// TS makes it really hard to dynamically create this object :/ so it's just hardcoded here.
|
||||
// Remember to wrap undoable reducers in `undoable()`!
|
||||
const ALL_REDUCERS = {
|
||||
[api.reducerPath]: api.reducer,
|
||||
[canvasSessionSliceConfig.slice.reducerPath]: canvasSessionSliceConfig.slice.reducer,
|
||||
[canvasSettingsSliceConfig.slice.reducerPath]: canvasSettingsSliceConfig.slice.reducer,
|
||||
// Undoable!
|
||||
[canvasSliceConfig.slice.reducerPath]: undoable(
|
||||
canvasSliceConfig.slice.reducer,
|
||||
canvasSliceConfig.undoableConfig?.reduxUndoOptions
|
||||
),
|
||||
[changeBoardModalSliceConfig.slice.reducerPath]: changeBoardModalSliceConfig.slice.reducer,
|
||||
[configSliceConfig.slice.reducerPath]: configSliceConfig.slice.reducer,
|
||||
[dynamicPromptsSliceConfig.slice.reducerPath]: dynamicPromptsSliceConfig.slice.reducer,
|
||||
[gallerySliceConfig.slice.reducerPath]: gallerySliceConfig.slice.reducer,
|
||||
[lorasSliceConfig.slice.reducerPath]: lorasSliceConfig.slice.reducer,
|
||||
[modelManagerSliceConfig.slice.reducerPath]: modelManagerSliceConfig.slice.reducer,
|
||||
// Undoable!
|
||||
[nodesSliceConfig.slice.reducerPath]: undoable(
|
||||
nodesSliceConfig.slice.reducer,
|
||||
nodesSliceConfig.undoableConfig?.reduxUndoOptions
|
||||
),
|
||||
[paramsSliceConfig.slice.reducerPath]: paramsSliceConfig.slice.reducer,
|
||||
[queueSliceConfig.slice.reducerPath]: queueSliceConfig.slice.reducer,
|
||||
[refImagesSliceConfig.slice.reducerPath]: refImagesSliceConfig.slice.reducer,
|
||||
[stylePresetSliceConfig.slice.reducerPath]: stylePresetSliceConfig.slice.reducer,
|
||||
[systemSliceConfig.slice.reducerPath]: systemSliceConfig.slice.reducer,
|
||||
[uiSliceConfig.slice.reducerPath]: uiSliceConfig.slice.reducer,
|
||||
[upscaleSliceConfig.slice.reducerPath]: upscaleSliceConfig.slice.reducer,
|
||||
[workflowLibrarySliceConfig.slice.reducerPath]: workflowLibrarySliceConfig.slice.reducer,
|
||||
[workflowSettingsSliceConfig.slice.reducerPath]: workflowSettingsSliceConfig.slice.reducer,
|
||||
};
|
||||
|
||||
const rootReducer = combineReducers(ALL_REDUCERS);
|
||||
|
||||
const rememberedRootReducer = rememberReducer(rootReducer);
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
export type PersistConfig<T = any> = {
|
||||
/**
|
||||
* The name of the slice.
|
||||
*/
|
||||
name: keyof typeof allReducers;
|
||||
/**
|
||||
* The initial state of the slice.
|
||||
*/
|
||||
initialState: T;
|
||||
/**
|
||||
* Migrate the state to the current version during rehydration.
|
||||
* @param state The rehydrated state.
|
||||
* @returns A correctly-shaped state.
|
||||
*/
|
||||
migrate: (state: unknown) => T;
|
||||
/**
|
||||
* Keys to omit from the persisted state.
|
||||
*/
|
||||
persistDenylist: (keyof T)[];
|
||||
};
|
||||
|
||||
const persistConfigs: { [key in keyof typeof allReducers]?: PersistConfig } = {
|
||||
[galleryPersistConfig.name]: galleryPersistConfig,
|
||||
[nodesPersistConfig.name]: nodesPersistConfig,
|
||||
[systemPersistConfig.name]: systemPersistConfig,
|
||||
[uiPersistConfig.name]: uiPersistConfig,
|
||||
[dynamicPromptsPersistConfig.name]: dynamicPromptsPersistConfig,
|
||||
[modelManagerV2PersistConfig.name]: modelManagerV2PersistConfig,
|
||||
[canvasPersistConfig.name]: canvasPersistConfig,
|
||||
[workflowSettingsPersistConfig.name]: workflowSettingsPersistConfig,
|
||||
[upscalePersistConfig.name]: upscalePersistConfig,
|
||||
[stylePresetPersistConfig.name]: stylePresetPersistConfig,
|
||||
[paramsPersistConfig.name]: paramsPersistConfig,
|
||||
[canvasSettingsPersistConfig.name]: canvasSettingsPersistConfig,
|
||||
[canvasStagingAreaPersistConfig.name]: canvasStagingAreaPersistConfig,
|
||||
[lorasPersistConfig.name]: lorasPersistConfig,
|
||||
[workflowLibraryPersistConfig.name]: workflowLibraryPersistConfig,
|
||||
[refImagesSlice.name]: refImagesPersistConfig,
|
||||
};
|
||||
|
||||
const unserialize: UnserializeFunction = (data, key) => {
|
||||
const persistConfig = persistConfigs[key as keyof typeof persistConfigs];
|
||||
if (!persistConfig) {
|
||||
const sliceConfig = SLICE_CONFIGS[key as keyof typeof SLICE_CONFIGS];
|
||||
if (!sliceConfig?.persistConfig) {
|
||||
throw new Error(`No persist config for slice "${key}"`);
|
||||
}
|
||||
const { getInitialState, persistConfig, undoableConfig } = sliceConfig;
|
||||
let state;
|
||||
try {
|
||||
const { initialState, migrate } = persistConfig;
|
||||
const initialState = getInitialState();
|
||||
const parsed = JSON.parse(data);
|
||||
|
||||
// strip out old keys
|
||||
const stripped = pick(deepClone(parsed), keys(initialState));
|
||||
// run (additive) migrations
|
||||
const migrated = migrate(stripped);
|
||||
/*
|
||||
* Merge in initial state as default values, covering any missing keys. You might be tempted to use _.defaultsDeep,
|
||||
* but that merges arrays by index and partial objects by key. Using an identity function as the customizer results
|
||||
* in behaviour like defaultsDeep, but doesn't overwrite any values that are not undefined in the migrated state.
|
||||
*/
|
||||
const transformed = mergeWith(migrated, initialState, (objVal) => objVal);
|
||||
const unPersistDenylisted = mergeWith(stripped, initialState, (objVal) => objVal);
|
||||
// run (additive) migrations
|
||||
const migrated = persistConfig.migrate(unPersistDenylisted);
|
||||
|
||||
log.debug(
|
||||
{
|
||||
persistedData: parsed,
|
||||
rehydratedData: transformed,
|
||||
diff: diff(parsed, transformed) as JsonObject, // this is always serializable
|
||||
persistedData: parsed as JsonObject,
|
||||
rehydratedData: migrated as JsonObject,
|
||||
diff: diff(data, migrated) as JsonObject,
|
||||
},
|
||||
`Rehydrated slice "${key}"`
|
||||
);
|
||||
state = transformed;
|
||||
state = migrated;
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{ error: serializeError(err as Error) },
|
||||
`Error rehydrating slice "${key}", falling back to default initial state`
|
||||
);
|
||||
state = persistConfig.initialState;
|
||||
state = getInitialState();
|
||||
}
|
||||
|
||||
// If the slice is undoable, we need to wrap it in a new history - only nodes and canvas are undoable at the moment.
|
||||
// TODO(psyche): make this automatic & remove the hard-coding for specific slices.
|
||||
if (key === nodesSlice.name || key === canvasSlice.name) {
|
||||
// Undoable slices must be wrapped in a history!
|
||||
if (undoableConfig) {
|
||||
return newHistory([], state, []);
|
||||
} else {
|
||||
return state;
|
||||
@@ -161,43 +167,53 @@ const unserialize: UnserializeFunction = (data, key) => {
|
||||
};
|
||||
|
||||
const serialize: SerializeFunction = (data, key) => {
|
||||
const persistConfig = persistConfigs[key as keyof typeof persistConfigs];
|
||||
if (!persistConfig) {
|
||||
const sliceConfig = SLICE_CONFIGS[key as keyof typeof SLICE_CONFIGS];
|
||||
if (!sliceConfig?.persistConfig) {
|
||||
throw new Error(`No persist config for slice "${key}"`);
|
||||
}
|
||||
// Heuristic to determine if the slice is undoable - could just hardcode it in the persistConfig
|
||||
const isUndoable = 'present' in data && 'past' in data && 'future' in data && '_latestUnfiltered' in data;
|
||||
const result = omit(isUndoable ? data.present : data, persistConfig.persistDenylist);
|
||||
|
||||
const result = omit(
|
||||
sliceConfig.undoableConfig ? data.present : data,
|
||||
sliceConfig.persistConfig.persistDenylist ?? []
|
||||
);
|
||||
|
||||
return JSON.stringify(result);
|
||||
};
|
||||
|
||||
export const createStore = (uniqueStoreKey?: string, persist = true) =>
|
||||
configureStore({
|
||||
const PERSISTED_KEYS = Object.values(SLICE_CONFIGS)
|
||||
.filter((sliceConfig) => !!sliceConfig.persistConfig)
|
||||
.map((sliceConfig) => sliceConfig.slice.reducerPath);
|
||||
|
||||
export const createStore = (options?: { persist?: boolean; persistThrottle?: number; onRehydrated?: () => void }) => {
|
||||
const store = configureStore({
|
||||
reducer: rememberedRootReducer,
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
// serializableCheck: false,
|
||||
// immutableCheck: false,
|
||||
serializableCheck: import.meta.env.MODE === 'development',
|
||||
immutableCheck: import.meta.env.MODE === 'development',
|
||||
})
|
||||
.concat(api.middleware)
|
||||
.concat(dynamicMiddlewares)
|
||||
.concat(authToastMiddleware)
|
||||
// .concat(getDebugLoggerMiddleware())
|
||||
// .concat(getDebugLoggerMiddleware({ withDiff: true, withNextState: true }))
|
||||
.prepend(listenerMiddleware.middleware),
|
||||
enhancers: (getDefaultEnhancers) => {
|
||||
const _enhancers = getDefaultEnhancers().concat(autoBatchEnhancer());
|
||||
if (persist) {
|
||||
_enhancers.push(
|
||||
rememberEnhancer(idbKeyValDriver, keys(persistConfigs), {
|
||||
persistDebounce: 300,
|
||||
const enhancers = getDefaultEnhancers();
|
||||
if (options?.persist) {
|
||||
return enhancers.prepend(
|
||||
rememberEnhancer(reduxRememberDriver, PERSISTED_KEYS, {
|
||||
persistThrottle: options?.persistThrottle ?? 2000,
|
||||
serialize,
|
||||
unserialize,
|
||||
prefix: uniqueStoreKey ? `${STORAGE_PREFIX}${uniqueStoreKey}-` : STORAGE_PREFIX,
|
||||
prefix: '',
|
||||
errorHandler,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
return enhancers;
|
||||
}
|
||||
return _enhancers;
|
||||
},
|
||||
devTools: {
|
||||
actionSanitizer,
|
||||
@@ -212,9 +228,62 @@ export const createStore = (uniqueStoreKey?: string, persist = true) =>
|
||||
},
|
||||
});
|
||||
|
||||
// Once-off listener to support waiting for rehydration before rendering the app
|
||||
startAppListening({
|
||||
actionCreator: createAction(REMEMBER_REHYDRATED),
|
||||
effect: (action, { unsubscribe }) => {
|
||||
unsubscribe();
|
||||
options?.onRehydrated?.();
|
||||
},
|
||||
});
|
||||
|
||||
return store;
|
||||
};
|
||||
|
||||
export type AppStore = ReturnType<typeof createStore>;
|
||||
export type RootState = ReturnType<AppStore['getState']>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
export type AppThunkDispatch = ThunkDispatch<RootState, any, UnknownAction>;
|
||||
export type AppDispatch = ReturnType<typeof createStore>['dispatch'];
|
||||
export type AppGetState = ReturnType<typeof createStore>['getState'];
|
||||
export type AppStartListening = TypedStartListening<RootState, AppDispatch>;
|
||||
|
||||
export const addAppListener = addListener.withTypes<RootState, AppDispatch>();
|
||||
|
||||
const startAppListening = listenerMiddleware.startListening as AppStartListening;
|
||||
addImageUploadedFulfilledListener(startAppListening);
|
||||
|
||||
// Image deleted
|
||||
addDeleteBoardAndImagesFulfilledListener(startAppListening);
|
||||
|
||||
// User Invoked
|
||||
addAnyEnqueuedListener(startAppListening);
|
||||
addBatchEnqueuedListener(startAppListening);
|
||||
|
||||
// Socket.IO
|
||||
addSocketConnectedEventListener(startAppListening);
|
||||
|
||||
// Gallery bulk download
|
||||
addBulkDownloadListeners(startAppListening);
|
||||
|
||||
// Boards
|
||||
addImageAddedToBoardFulfilledListener(startAppListening);
|
||||
addImageRemovedFromBoardFulfilledListener(startAppListening);
|
||||
addBoardIdSelectedListener(startAppListening);
|
||||
addArchivedOrDeletedBoardListener(startAppListening);
|
||||
|
||||
// Node schemas
|
||||
addGetOpenAPISchemaListener(startAppListening);
|
||||
|
||||
// Models
|
||||
addModelSelectedListener(startAppListening);
|
||||
|
||||
// app startup
|
||||
addAppStartedListener(startAppListening);
|
||||
addModelsLoadedListener(startAppListening);
|
||||
addAppConfigReceivedListener(startAppListening);
|
||||
|
||||
// Ad-hoc upscale workflwo
|
||||
addAdHocPostProcessingRequestedListener(startAppListening);
|
||||
|
||||
addSetDefaultSettingsListener(startAppListening);
|
||||
|
||||
46
invokeai/frontend/web/src/app/store/types.ts
Normal file
46
invokeai/frontend/web/src/app/store/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Slice } from '@reduxjs/toolkit';
|
||||
import type { UndoableOptions } from 'redux-undo';
|
||||
import type { ZodType } from 'zod';
|
||||
|
||||
type StateFromSlice<T extends Slice> = T extends Slice<infer U> ? U : never;
|
||||
|
||||
export type SliceConfig<T extends Slice> = {
|
||||
/**
|
||||
* The redux slice (return of createSlice).
|
||||
*/
|
||||
slice: T;
|
||||
/**
|
||||
* The zod schema for the slice.
|
||||
*/
|
||||
schema: ZodType<StateFromSlice<T>>;
|
||||
/**
|
||||
* A function that returns the initial state of the slice.
|
||||
*/
|
||||
getInitialState: () => StateFromSlice<T>;
|
||||
/**
|
||||
* The optional persist configuration for this slice. If omitted, the slice will not be persisted.
|
||||
*/
|
||||
persistConfig?: {
|
||||
/**
|
||||
* Migrate the state to the current version during rehydration. This method should throw an error if the migration
|
||||
* fails.
|
||||
*
|
||||
* @param state The rehydrated state.
|
||||
* @returns A correctly-shaped state.
|
||||
*/
|
||||
migrate: (state: unknown) => StateFromSlice<T>;
|
||||
/**
|
||||
* Keys to omit from the persisted state.
|
||||
*/
|
||||
persistDenylist?: (keyof StateFromSlice<T>)[];
|
||||
};
|
||||
/**
|
||||
* The optional undoable configuration for this slice. If omitted, the slice will not be undoable.
|
||||
*/
|
||||
undoableConfig?: {
|
||||
/**
|
||||
* The options to be passed into redux-undo.
|
||||
*/
|
||||
reduxUndoOptions: UndoableOptions<StateFromSlice<T>>;
|
||||
};
|
||||
};
|
||||
@@ -1,130 +1,299 @@
|
||||
import type { FilterType } from 'features/controlLayers/store/filters';
|
||||
import type { ParameterPrecision, ParameterScheduler } from 'features/parameters/types/parameterSchemas';
|
||||
import type { TabName } from 'features/ui/store/uiTypes';
|
||||
import { zFilterType } from 'features/controlLayers/store/filters';
|
||||
import { zParameterPrecision, zParameterScheduler } from 'features/parameters/types/parameterSchemas';
|
||||
import { zTabName } from 'features/ui/store/uiTypes';
|
||||
import type { PartialDeep } from 'type-fest';
|
||||
import z from 'zod';
|
||||
|
||||
/**
|
||||
* A disable-able application feature
|
||||
*/
|
||||
export type AppFeature =
|
||||
| 'faceRestore'
|
||||
| 'upscaling'
|
||||
| 'lightbox'
|
||||
| 'modelManager'
|
||||
| 'githubLink'
|
||||
| 'discordLink'
|
||||
| 'bugLink'
|
||||
| 'aboutModal'
|
||||
| 'localization'
|
||||
| 'consoleLogging'
|
||||
| 'dynamicPrompting'
|
||||
| 'batches'
|
||||
| 'syncModels'
|
||||
| 'multiselect'
|
||||
| 'pauseQueue'
|
||||
| 'resumeQueue'
|
||||
| 'invocationCache'
|
||||
| 'modelCache'
|
||||
| 'bulkDownload'
|
||||
| 'starterModels'
|
||||
| 'hfToken'
|
||||
| 'retryQueueItem'
|
||||
| 'cancelAndClearAll'
|
||||
| 'chatGPT4oHigh'
|
||||
| 'modelRelationships';
|
||||
/**
|
||||
* A disable-able Stable Diffusion feature
|
||||
*/
|
||||
export type SDFeature =
|
||||
| 'controlNet'
|
||||
| 'noise'
|
||||
| 'perlinNoise'
|
||||
| 'noiseThreshold'
|
||||
| 'variation'
|
||||
| 'symmetry'
|
||||
| 'seamless'
|
||||
| 'hires'
|
||||
| 'lora'
|
||||
| 'embedding'
|
||||
| 'vae'
|
||||
| 'hrf';
|
||||
const zAppFeature = z.enum([
|
||||
'faceRestore',
|
||||
'upscaling',
|
||||
'lightbox',
|
||||
'modelManager',
|
||||
'githubLink',
|
||||
'discordLink',
|
||||
'bugLink',
|
||||
'aboutModal',
|
||||
'localization',
|
||||
'consoleLogging',
|
||||
'dynamicPrompting',
|
||||
'batches',
|
||||
'syncModels',
|
||||
'multiselect',
|
||||
'pauseQueue',
|
||||
'resumeQueue',
|
||||
'invocationCache',
|
||||
'modelCache',
|
||||
'bulkDownload',
|
||||
'starterModels',
|
||||
'hfToken',
|
||||
'retryQueueItem',
|
||||
'cancelAndClearAll',
|
||||
'chatGPT4oHigh',
|
||||
'modelRelationships',
|
||||
]);
|
||||
export type AppFeature = z.infer<typeof zAppFeature>;
|
||||
|
||||
export type NumericalParameterConfig = {
|
||||
initial: number;
|
||||
sliderMin: number;
|
||||
sliderMax: number;
|
||||
numberInputMin: number;
|
||||
numberInputMax: number;
|
||||
fineStep: number;
|
||||
coarseStep: number;
|
||||
};
|
||||
const zSDFeature = z.enum([
|
||||
'controlNet',
|
||||
'noise',
|
||||
'perlinNoise',
|
||||
'noiseThreshold',
|
||||
'variation',
|
||||
'symmetry',
|
||||
'seamless',
|
||||
'hires',
|
||||
'lora',
|
||||
'embedding',
|
||||
'vae',
|
||||
'hrf',
|
||||
]);
|
||||
export type SDFeature = z.infer<typeof zSDFeature>;
|
||||
|
||||
const zNumericalParameterConfig = z.object({
|
||||
initial: z.number().default(512),
|
||||
sliderMin: z.number().default(64),
|
||||
sliderMax: z.number().default(1536),
|
||||
numberInputMin: z.number().default(64),
|
||||
numberInputMax: z.number().default(4096),
|
||||
fineStep: z.number().default(8),
|
||||
coarseStep: z.number().default(64),
|
||||
});
|
||||
|
||||
/**
|
||||
* Configuration options for the InvokeAI UI.
|
||||
* Distinct from system settings which may be changed inside the app.
|
||||
*/
|
||||
export type AppConfig = {
|
||||
export const zAppConfig = z.object({
|
||||
/**
|
||||
* Whether or not we should update image urls when image loading errors
|
||||
*/
|
||||
shouldUpdateImagesOnConnect: boolean;
|
||||
shouldFetchMetadataFromApi: boolean;
|
||||
shouldUpdateImagesOnConnect: z.boolean(),
|
||||
shouldFetchMetadataFromApi: z.boolean(),
|
||||
/**
|
||||
* Sets a size limit for outputs on the upscaling tab. This is a maximum dimension, so the actual max number of pixels
|
||||
* will be the square of this value.
|
||||
*/
|
||||
maxUpscaleDimension?: number;
|
||||
allowPrivateBoards: boolean;
|
||||
allowPrivateStylePresets: boolean;
|
||||
allowClientSideUpload: boolean;
|
||||
allowPublishWorkflows: boolean;
|
||||
allowPromptExpansion: boolean;
|
||||
disabledTabs: TabName[];
|
||||
disabledFeatures: AppFeature[];
|
||||
disabledSDFeatures: SDFeature[];
|
||||
nodesAllowlist: string[] | undefined;
|
||||
nodesDenylist: string[] | undefined;
|
||||
metadataFetchDebounce?: number;
|
||||
workflowFetchDebounce?: number;
|
||||
isLocal?: boolean;
|
||||
shouldShowCredits: boolean;
|
||||
sd: {
|
||||
defaultModel?: string;
|
||||
disabledControlNetModels: string[];
|
||||
disabledControlNetProcessors: FilterType[];
|
||||
maxUpscaleDimension: z.number().optional(),
|
||||
allowPrivateBoards: z.boolean(),
|
||||
allowPrivateStylePresets: z.boolean(),
|
||||
allowClientSideUpload: z.boolean(),
|
||||
allowPublishWorkflows: z.boolean(),
|
||||
allowPromptExpansion: z.boolean(),
|
||||
disabledTabs: z.array(zTabName),
|
||||
disabledFeatures: z.array(zAppFeature),
|
||||
disabledSDFeatures: z.array(zSDFeature),
|
||||
nodesAllowlist: z.array(z.string()).optional(),
|
||||
nodesDenylist: z.array(z.string()).optional(),
|
||||
metadataFetchDebounce: z.number().int().optional(),
|
||||
workflowFetchDebounce: z.number().int().optional(),
|
||||
isLocal: z.boolean().optional(),
|
||||
shouldShowCredits: z.boolean().optional(),
|
||||
sd: z.object({
|
||||
defaultModel: z.string().optional(),
|
||||
disabledControlNetModels: z.array(z.string()),
|
||||
disabledControlNetProcessors: z.array(zFilterType),
|
||||
// Core parameters
|
||||
iterations: NumericalParameterConfig;
|
||||
width: NumericalParameterConfig; // initial value comes from model
|
||||
height: NumericalParameterConfig; // initial value comes from model
|
||||
steps: NumericalParameterConfig;
|
||||
guidance: NumericalParameterConfig;
|
||||
cfgRescaleMultiplier: NumericalParameterConfig;
|
||||
img2imgStrength: NumericalParameterConfig;
|
||||
scheduler?: ParameterScheduler;
|
||||
vaePrecision?: ParameterPrecision;
|
||||
iterations: zNumericalParameterConfig,
|
||||
width: zNumericalParameterConfig,
|
||||
height: zNumericalParameterConfig,
|
||||
steps: zNumericalParameterConfig,
|
||||
guidance: zNumericalParameterConfig,
|
||||
cfgRescaleMultiplier: zNumericalParameterConfig,
|
||||
img2imgStrength: zNumericalParameterConfig,
|
||||
scheduler: zParameterScheduler.optional(),
|
||||
vaePrecision: zParameterPrecision.optional(),
|
||||
// Canvas
|
||||
boundingBoxHeight: NumericalParameterConfig; // initial value comes from model
|
||||
boundingBoxWidth: NumericalParameterConfig; // initial value comes from model
|
||||
scaledBoundingBoxHeight: NumericalParameterConfig; // initial value comes from model
|
||||
scaledBoundingBoxWidth: NumericalParameterConfig; // initial value comes from model
|
||||
canvasCoherenceStrength: NumericalParameterConfig;
|
||||
canvasCoherenceEdgeSize: NumericalParameterConfig;
|
||||
infillTileSize: NumericalParameterConfig;
|
||||
infillPatchmatchDownscaleSize: NumericalParameterConfig;
|
||||
boundingBoxHeight: zNumericalParameterConfig,
|
||||
boundingBoxWidth: zNumericalParameterConfig,
|
||||
scaledBoundingBoxHeight: zNumericalParameterConfig,
|
||||
scaledBoundingBoxWidth: zNumericalParameterConfig,
|
||||
canvasCoherenceStrength: zNumericalParameterConfig,
|
||||
canvasCoherenceEdgeSize: zNumericalParameterConfig,
|
||||
infillTileSize: zNumericalParameterConfig,
|
||||
infillPatchmatchDownscaleSize: zNumericalParameterConfig,
|
||||
// Misc advanced
|
||||
clipSkip: NumericalParameterConfig; // slider and input max are ignored for this, because the values depend on the model
|
||||
maskBlur: NumericalParameterConfig;
|
||||
hrfStrength: NumericalParameterConfig;
|
||||
dynamicPrompts: {
|
||||
maxPrompts: NumericalParameterConfig;
|
||||
};
|
||||
ca: {
|
||||
weight: NumericalParameterConfig;
|
||||
};
|
||||
};
|
||||
flux: {
|
||||
guidance: NumericalParameterConfig;
|
||||
};
|
||||
};
|
||||
clipSkip: zNumericalParameterConfig, // slider and input max are ignored for this, because the values depend on the model
|
||||
maskBlur: zNumericalParameterConfig,
|
||||
hrfStrength: zNumericalParameterConfig,
|
||||
dynamicPrompts: z.object({
|
||||
maxPrompts: zNumericalParameterConfig,
|
||||
}),
|
||||
ca: z.object({
|
||||
weight: zNumericalParameterConfig,
|
||||
}),
|
||||
}),
|
||||
flux: z.object({
|
||||
guidance: zNumericalParameterConfig,
|
||||
}),
|
||||
});
|
||||
|
||||
export type AppConfig = z.infer<typeof zAppConfig>;
|
||||
export type PartialAppConfig = PartialDeep<AppConfig>;
|
||||
|
||||
export const getDefaultAppConfig = (): AppConfig => ({
|
||||
isLocal: true,
|
||||
shouldUpdateImagesOnConnect: false,
|
||||
shouldFetchMetadataFromApi: false,
|
||||
allowPrivateBoards: false,
|
||||
allowPrivateStylePresets: false,
|
||||
allowClientSideUpload: false,
|
||||
allowPublishWorkflows: false,
|
||||
allowPromptExpansion: false,
|
||||
shouldShowCredits: false,
|
||||
disabledTabs: [],
|
||||
disabledFeatures: ['lightbox', 'faceRestore', 'batches'] satisfies AppFeature[],
|
||||
disabledSDFeatures: ['variation', 'symmetry', 'hires', 'perlinNoise', 'noiseThreshold'] satisfies SDFeature[],
|
||||
sd: {
|
||||
disabledControlNetModels: [],
|
||||
disabledControlNetProcessors: [],
|
||||
iterations: {
|
||||
initial: 1,
|
||||
sliderMin: 1,
|
||||
sliderMax: 1000,
|
||||
numberInputMin: 1,
|
||||
numberInputMax: 10000,
|
||||
fineStep: 1,
|
||||
coarseStep: 1,
|
||||
},
|
||||
width: zNumericalParameterConfig.parse({}), // initial value comes from model
|
||||
height: zNumericalParameterConfig.parse({}), // initial value comes from model
|
||||
boundingBoxWidth: zNumericalParameterConfig.parse({}), // initial value comes from model
|
||||
boundingBoxHeight: zNumericalParameterConfig.parse({}), // initial value comes from model
|
||||
scaledBoundingBoxWidth: zNumericalParameterConfig.parse({}), // initial value comes from model
|
||||
scaledBoundingBoxHeight: zNumericalParameterConfig.parse({}), // initial value comes from model
|
||||
scheduler: 'dpmpp_3m_k' as const,
|
||||
vaePrecision: 'fp32' as const,
|
||||
steps: {
|
||||
initial: 30,
|
||||
sliderMin: 1,
|
||||
sliderMax: 100,
|
||||
numberInputMin: 1,
|
||||
numberInputMax: 500,
|
||||
fineStep: 1,
|
||||
coarseStep: 1,
|
||||
},
|
||||
guidance: {
|
||||
initial: 7,
|
||||
sliderMin: 1,
|
||||
sliderMax: 20,
|
||||
numberInputMin: 1,
|
||||
numberInputMax: 200,
|
||||
fineStep: 0.1,
|
||||
coarseStep: 0.5,
|
||||
},
|
||||
img2imgStrength: {
|
||||
initial: 0.7,
|
||||
sliderMin: 0,
|
||||
sliderMax: 1,
|
||||
numberInputMin: 0,
|
||||
numberInputMax: 1,
|
||||
fineStep: 0.01,
|
||||
coarseStep: 0.05,
|
||||
},
|
||||
canvasCoherenceStrength: {
|
||||
initial: 0.3,
|
||||
sliderMin: 0,
|
||||
sliderMax: 1,
|
||||
numberInputMin: 0,
|
||||
numberInputMax: 1,
|
||||
fineStep: 0.01,
|
||||
coarseStep: 0.05,
|
||||
},
|
||||
hrfStrength: {
|
||||
initial: 0.45,
|
||||
sliderMin: 0,
|
||||
sliderMax: 1,
|
||||
numberInputMin: 0,
|
||||
numberInputMax: 1,
|
||||
fineStep: 0.01,
|
||||
coarseStep: 0.05,
|
||||
},
|
||||
canvasCoherenceEdgeSize: {
|
||||
initial: 16,
|
||||
sliderMin: 0,
|
||||
sliderMax: 128,
|
||||
numberInputMin: 0,
|
||||
numberInputMax: 1024,
|
||||
fineStep: 8,
|
||||
coarseStep: 16,
|
||||
},
|
||||
cfgRescaleMultiplier: {
|
||||
initial: 0,
|
||||
sliderMin: 0,
|
||||
sliderMax: 0.99,
|
||||
numberInputMin: 0,
|
||||
numberInputMax: 0.99,
|
||||
fineStep: 0.05,
|
||||
coarseStep: 0.1,
|
||||
},
|
||||
clipSkip: {
|
||||
initial: 0,
|
||||
sliderMin: 0,
|
||||
sliderMax: 12, // determined by model selection, unused in practice
|
||||
numberInputMin: 0,
|
||||
numberInputMax: 12, // determined by model selection, unused in practice
|
||||
fineStep: 1,
|
||||
coarseStep: 1,
|
||||
},
|
||||
infillPatchmatchDownscaleSize: {
|
||||
initial: 1,
|
||||
sliderMin: 1,
|
||||
sliderMax: 10,
|
||||
numberInputMin: 1,
|
||||
numberInputMax: 10,
|
||||
fineStep: 1,
|
||||
coarseStep: 1,
|
||||
},
|
||||
infillTileSize: {
|
||||
initial: 32,
|
||||
sliderMin: 16,
|
||||
sliderMax: 64,
|
||||
numberInputMin: 16,
|
||||
numberInputMax: 256,
|
||||
fineStep: 1,
|
||||
coarseStep: 1,
|
||||
},
|
||||
maskBlur: {
|
||||
initial: 16,
|
||||
sliderMin: 0,
|
||||
sliderMax: 128,
|
||||
numberInputMin: 0,
|
||||
numberInputMax: 512,
|
||||
fineStep: 1,
|
||||
coarseStep: 1,
|
||||
},
|
||||
ca: {
|
||||
weight: {
|
||||
initial: 1,
|
||||
sliderMin: 0,
|
||||
sliderMax: 2,
|
||||
numberInputMin: -1,
|
||||
numberInputMax: 2,
|
||||
fineStep: 0.01,
|
||||
coarseStep: 0.05,
|
||||
},
|
||||
},
|
||||
dynamicPrompts: {
|
||||
maxPrompts: {
|
||||
initial: 100,
|
||||
sliderMin: 1,
|
||||
sliderMax: 1000,
|
||||
numberInputMin: 1,
|
||||
numberInputMax: 10000,
|
||||
fineStep: 1,
|
||||
coarseStep: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
flux: {
|
||||
guidance: {
|
||||
initial: 4,
|
||||
sliderMin: 2,
|
||||
sliderMax: 6,
|
||||
numberInputMin: 1,
|
||||
numberInputMax: 20,
|
||||
fineStep: 0.1,
|
||||
coarseStep: 0.5,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { clearIdbKeyValStore } from 'app/store/enhancers/reduxRemember/driver';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export const useClearStorage = () => {
|
||||
const clearStorage = useCallback(() => {
|
||||
clearIdbKeyValStore();
|
||||
localStorage.clear();
|
||||
}, []);
|
||||
|
||||
return clearStorage;
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { ChangeBoardModalState } from './types';
|
||||
|
||||
export const initialState: ChangeBoardModalState = {
|
||||
isModalOpen: false,
|
||||
image_names: [],
|
||||
};
|
||||
@@ -1,12 +1,20 @@
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import type { RootState } from 'app/store/store';
|
||||
import type { SliceConfig } from 'app/store/types';
|
||||
import z from 'zod';
|
||||
|
||||
import { initialState } from './initialState';
|
||||
const zChangeBoardModalState = z.object({
|
||||
isModalOpen: z.boolean().default(false),
|
||||
image_names: z.array(z.string()).default(() => []),
|
||||
});
|
||||
type ChangeBoardModalState = z.infer<typeof zChangeBoardModalState>;
|
||||
|
||||
export const changeBoardModalSlice = createSlice({
|
||||
const getInitialState = (): ChangeBoardModalState => zChangeBoardModalState.parse({});
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'changeBoardModal',
|
||||
initialState,
|
||||
initialState: getInitialState(),
|
||||
reducers: {
|
||||
isModalOpenChanged: (state, action: PayloadAction<boolean>) => {
|
||||
state.isModalOpen = action.payload;
|
||||
@@ -21,6 +29,12 @@ export const changeBoardModalSlice = createSlice({
|
||||
},
|
||||
});
|
||||
|
||||
export const { isModalOpenChanged, imagesToChangeSelected, changeBoardReset } = changeBoardModalSlice.actions;
|
||||
export const { isModalOpenChanged, imagesToChangeSelected, changeBoardReset } = slice.actions;
|
||||
|
||||
export const selectChangeBoardModalSlice = (state: RootState) => state.changeBoardModal;
|
||||
|
||||
export const changeBoardModalSliceConfig: SliceConfig<typeof slice> = {
|
||||
slice,
|
||||
schema: zChangeBoardModalState,
|
||||
getInitialState,
|
||||
};
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export type ChangeBoardModalState = {
|
||||
isModalOpen: boolean;
|
||||
image_names: string[];
|
||||
};
|
||||
@@ -63,6 +63,7 @@ RefImageList.displayName = 'RefImageList';
|
||||
const dndTargetData = addGlobalReferenceImageDndTarget.getData();
|
||||
|
||||
const MaxRefImages = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Button
|
||||
position="relative"
|
||||
@@ -75,7 +76,7 @@ const MaxRefImages = memo(() => {
|
||||
borderRadius="base"
|
||||
isDisabled
|
||||
>
|
||||
Max Ref Images
|
||||
{t('controlLayers.maxRefImages')}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
@@ -83,6 +84,7 @@ MaxRefImages.displayName = 'MaxRefImages';
|
||||
|
||||
const AddRefImageDropTargetAndButton = memo(() => {
|
||||
const { dispatch, getState } = useAppStore();
|
||||
const { t } = useTranslation();
|
||||
const tab = useAppSelector(selectActiveTab);
|
||||
|
||||
const uploadOptions = useMemo(
|
||||
@@ -114,7 +116,7 @@ const AddRefImageDropTargetAndButton = memo(() => {
|
||||
leftIcon={<PiUploadBold />}
|
||||
{...uploadApi.getUploadButtonProps()}
|
||||
>
|
||||
Reference Image
|
||||
{t('controlLayers.referenceImage')}
|
||||
<input {...uploadApi.getUploadInputProps()} />
|
||||
<DndDropTarget label="Drop" dndTarget={addGlobalReferenceImageDndTarget} dndTargetData={dndTargetData} />
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { $alt, $ctrl, $meta, $shift } from '@invoke-ai/ui-library';
|
||||
import type { Selector } from '@reduxjs/toolkit';
|
||||
import { addAppListener } from 'app/store/middleware/listenerMiddleware';
|
||||
import type { AppStore, RootState } from 'app/store/store';
|
||||
import { addAppListener } from 'app/store/store';
|
||||
import type { CanvasEntityAdapterControlLayer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterControlLayer';
|
||||
import type { CanvasEntityAdapterRasterLayer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
@@ -319,6 +319,14 @@ export class CanvasStateApiModule extends CanvasModuleBase {
|
||||
getPositionGridSize = (): number => {
|
||||
const snapToGrid = this.getSettings().snapToGrid;
|
||||
if (!snapToGrid) {
|
||||
const overrideSnap = this.$ctrlKey.get() || this.$metaKey.get();
|
||||
if (overrideSnap) {
|
||||
const useFine = this.$shiftKey.get();
|
||||
if (useFine) {
|
||||
return 8;
|
||||
}
|
||||
return 64;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
const useFine = this.$ctrlKey.get() || this.$metaKey.get();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PayloadAction, Selector } from '@reduxjs/toolkit';
|
||||
import { createSelector, createSlice } from '@reduxjs/toolkit';
|
||||
import type { PersistConfig, RootState } from 'app/store/store';
|
||||
import type { RootState } from 'app/store/store';
|
||||
import type { SliceConfig } from 'app/store/types';
|
||||
import { zRgbaColor } from 'features/controlLayers/store/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -11,32 +12,32 @@ const zCanvasSettingsState = z.object({
|
||||
/**
|
||||
* Whether to show HUD (Heads-Up Display) on the canvas.
|
||||
*/
|
||||
showHUD: z.boolean().default(true),
|
||||
showHUD: z.boolean(),
|
||||
/**
|
||||
* Whether to clip lines and shapes to the generation bounding box. If disabled, lines and shapes will be clipped to
|
||||
* the canvas bounds.
|
||||
*/
|
||||
clipToBbox: z.boolean().default(false),
|
||||
clipToBbox: z.boolean(),
|
||||
/**
|
||||
* Whether to show a dynamic grid on the canvas. If disabled, a checkerboard pattern will be shown instead.
|
||||
*/
|
||||
dynamicGrid: z.boolean().default(false),
|
||||
dynamicGrid: z.boolean(),
|
||||
/**
|
||||
* Whether to invert the scroll direction when adjusting the brush or eraser width with the scroll wheel.
|
||||
*/
|
||||
invertScrollForToolWidth: z.boolean().default(false),
|
||||
invertScrollForToolWidth: z.boolean(),
|
||||
/**
|
||||
* The width of the brush tool.
|
||||
*/
|
||||
brushWidth: z.int().gt(0).default(50),
|
||||
brushWidth: z.int().gt(0),
|
||||
/**
|
||||
* The width of the eraser tool.
|
||||
*/
|
||||
eraserWidth: z.int().gt(0).default(50),
|
||||
eraserWidth: z.int().gt(0),
|
||||
/**
|
||||
* The color to use when drawing lines or filling shapes.
|
||||
*/
|
||||
color: zRgbaColor.default({ r: 31, g: 160, b: 224, a: 1 }), // invokeBlue.500
|
||||
color: zRgbaColor,
|
||||
/**
|
||||
* Whether to composite inpainted/outpainted regions back onto the source image when saving canvas generations.
|
||||
*
|
||||
@@ -44,57 +45,77 @@ const zCanvasSettingsState = z.object({
|
||||
*
|
||||
* When `sendToCanvas` is disabled, this setting is ignored, masked regions will always be composited.
|
||||
*/
|
||||
outputOnlyMaskedRegions: z.boolean().default(true),
|
||||
outputOnlyMaskedRegions: z.boolean(),
|
||||
/**
|
||||
* Whether to automatically process the operations like filtering and auto-masking.
|
||||
*/
|
||||
autoProcess: z.boolean().default(true),
|
||||
autoProcess: z.boolean(),
|
||||
/**
|
||||
* The snap-to-grid setting for the canvas.
|
||||
*/
|
||||
snapToGrid: z.boolean().default(true),
|
||||
snapToGrid: z.boolean(),
|
||||
/**
|
||||
* Whether to show progress on the canvas when generating images.
|
||||
*/
|
||||
showProgressOnCanvas: z.boolean().default(true),
|
||||
showProgressOnCanvas: z.boolean(),
|
||||
/**
|
||||
* Whether to show the bounding box overlay on the canvas.
|
||||
*/
|
||||
bboxOverlay: z.boolean().default(false),
|
||||
bboxOverlay: z.boolean(),
|
||||
/**
|
||||
* Whether to preserve the masked region instead of inpainting it.
|
||||
*/
|
||||
preserveMask: z.boolean().default(false),
|
||||
preserveMask: z.boolean(),
|
||||
/**
|
||||
* Whether to show only raster layers while staging.
|
||||
*/
|
||||
isolatedStagingPreview: z.boolean().default(true),
|
||||
isolatedStagingPreview: z.boolean(),
|
||||
/**
|
||||
* Whether to show only the selected layer while filtering, transforming, or doing other operations.
|
||||
*/
|
||||
isolatedLayerPreview: z.boolean().default(true),
|
||||
isolatedLayerPreview: z.boolean(),
|
||||
/**
|
||||
* Whether to use pressure sensitivity for the brush and eraser tool when a pen device is used.
|
||||
*/
|
||||
pressureSensitivity: z.boolean().default(true),
|
||||
pressureSensitivity: z.boolean(),
|
||||
/**
|
||||
* Whether to show the rule of thirds composition guide overlay on the canvas.
|
||||
*/
|
||||
ruleOfThirds: z.boolean().default(false),
|
||||
ruleOfThirds: z.boolean(),
|
||||
/**
|
||||
* Whether to save all staging images to the gallery instead of keeping them as intermediate images.
|
||||
*/
|
||||
saveAllImagesToGallery: z.boolean().default(false),
|
||||
saveAllImagesToGallery: z.boolean(),
|
||||
/**
|
||||
* The auto-switch mode for the canvas staging area.
|
||||
*/
|
||||
stagingAreaAutoSwitch: zAutoSwitchMode.default('switch_on_start'),
|
||||
stagingAreaAutoSwitch: zAutoSwitchMode,
|
||||
});
|
||||
|
||||
type CanvasSettingsState = z.infer<typeof zCanvasSettingsState>;
|
||||
const getInitialState = () => zCanvasSettingsState.parse({});
|
||||
const getInitialState = (): CanvasSettingsState => ({
|
||||
showHUD: true,
|
||||
clipToBbox: false,
|
||||
dynamicGrid: false,
|
||||
invertScrollForToolWidth: false,
|
||||
brushWidth: 50,
|
||||
eraserWidth: 50,
|
||||
color: { r: 31, g: 160, b: 224, a: 1 }, // invokeBlue.500
|
||||
outputOnlyMaskedRegions: true,
|
||||
autoProcess: true,
|
||||
snapToGrid: true,
|
||||
showProgressOnCanvas: true,
|
||||
bboxOverlay: false,
|
||||
preserveMask: false,
|
||||
isolatedStagingPreview: true,
|
||||
isolatedLayerPreview: true,
|
||||
pressureSensitivity: true,
|
||||
ruleOfThirds: false,
|
||||
saveAllImagesToGallery: false,
|
||||
stagingAreaAutoSwitch: 'switch_on_start',
|
||||
});
|
||||
|
||||
export const canvasSettingsSlice = createSlice({
|
||||
const slice = createSlice({
|
||||
name: 'canvasSettings',
|
||||
initialState: getInitialState(),
|
||||
reducers: {
|
||||
@@ -184,18 +205,15 @@ export const {
|
||||
settingsRuleOfThirdsToggled,
|
||||
settingsSaveAllImagesToGalleryToggled,
|
||||
settingsStagingAreaAutoSwitchChanged,
|
||||
} = canvasSettingsSlice.actions;
|
||||
} = slice.actions;
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
const migrate = (state: any): any => {
|
||||
return state;
|
||||
};
|
||||
|
||||
export const canvasSettingsPersistConfig: PersistConfig<CanvasSettingsState> = {
|
||||
name: canvasSettingsSlice.name,
|
||||
initialState: getInitialState(),
|
||||
migrate,
|
||||
persistDenylist: [],
|
||||
export const canvasSettingsSliceConfig: SliceConfig<typeof slice> = {
|
||||
slice,
|
||||
schema: zCanvasSettingsState,
|
||||
getInitialState,
|
||||
persistConfig: {
|
||||
migrate: (state) => zCanvasSettingsState.parse(state),
|
||||
},
|
||||
};
|
||||
|
||||
export const selectCanvasSettingsSlice = (s: RootState) => s.canvasSettings;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PayloadAction, UnknownAction } from '@reduxjs/toolkit';
|
||||
import { createSlice, isAnyOf } from '@reduxjs/toolkit';
|
||||
import type { PersistConfig } from 'app/store/store';
|
||||
import type { SliceConfig } from 'app/store/types';
|
||||
import { moveOneToEnd, moveOneToStart, moveToEnd, moveToStart } from 'common/util/arrayUtils';
|
||||
import { deepClone } from 'common/util/deepClone';
|
||||
import { roundDownToMultiple, roundToMultiple } from 'common/util/roundDownToMultiple';
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
isFLUXReduxConfig,
|
||||
isImagenAspectRatioID,
|
||||
isIPAdapterConfig,
|
||||
zCanvasState,
|
||||
} from './types';
|
||||
import {
|
||||
converters,
|
||||
@@ -95,7 +96,7 @@ import {
|
||||
initialT2IAdapter,
|
||||
} from './util';
|
||||
|
||||
export const canvasSlice = createSlice({
|
||||
const slice = createSlice({
|
||||
name: 'canvas',
|
||||
initialState: getInitialCanvasState(),
|
||||
reducers: {
|
||||
@@ -1675,19 +1676,7 @@ export const {
|
||||
inpaintMaskDenoiseLimitChanged,
|
||||
inpaintMaskDenoiseLimitDeleted,
|
||||
// inpaintMaskRecalled,
|
||||
} = canvasSlice.actions;
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
const migrate = (state: any): any => {
|
||||
return state;
|
||||
};
|
||||
|
||||
export const canvasPersistConfig: PersistConfig<CanvasState> = {
|
||||
name: canvasSlice.name,
|
||||
initialState: getInitialCanvasState(),
|
||||
migrate,
|
||||
persistDenylist: [],
|
||||
};
|
||||
} = slice.actions;
|
||||
|
||||
const syncScaledSize = (state: CanvasState) => {
|
||||
if (API_BASE_MODELS.includes(state.bbox.modelBase)) {
|
||||
@@ -1710,14 +1699,14 @@ const syncScaledSize = (state: CanvasState) => {
|
||||
|
||||
let filter = true;
|
||||
|
||||
export const canvasUndoableConfig: UndoableOptions<CanvasState, UnknownAction> = {
|
||||
const canvasUndoableConfig: UndoableOptions<CanvasState, UnknownAction> = {
|
||||
limit: 64,
|
||||
undoType: canvasUndo.type,
|
||||
redoType: canvasRedo.type,
|
||||
clearHistoryType: canvasClearHistory.type,
|
||||
filter: (action, _state, _history) => {
|
||||
// Ignore all actions from other slices
|
||||
if (!action.type.startsWith(canvasSlice.name)) {
|
||||
if (!action.type.startsWith(slice.name)) {
|
||||
return false;
|
||||
}
|
||||
// Throttle rapid actions of the same type
|
||||
@@ -1728,6 +1717,18 @@ export const canvasUndoableConfig: UndoableOptions<CanvasState, UnknownAction> =
|
||||
// debug: import.meta.env.MODE === 'development',
|
||||
};
|
||||
|
||||
export const canvasSliceConfig: SliceConfig<typeof slice> = {
|
||||
slice,
|
||||
getInitialState: getInitialCanvasState,
|
||||
schema: zCanvasState,
|
||||
persistConfig: {
|
||||
migrate: (state) => zCanvasState.parse(state),
|
||||
},
|
||||
undoableConfig: {
|
||||
reduxUndoOptions: canvasUndoableConfig,
|
||||
},
|
||||
};
|
||||
|
||||
const doNotGroupMatcher = isAnyOf(entityBrushLineAdded, entityEraserLineAdded, entityRectAdded);
|
||||
|
||||
// Store rapid actions of the same type at most once every x time.
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
import { EMPTY_ARRAY } from 'app/store/constants';
|
||||
import type { PersistConfig, RootState } from 'app/store/store';
|
||||
import type { RootState } from 'app/store/store';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { deepClone } from 'common/util/deepClone';
|
||||
import type { SliceConfig } from 'app/store/types';
|
||||
import { isPlainObject } from 'es-toolkit';
|
||||
import { getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import { useMemo } from 'react';
|
||||
import { queueApi } from 'services/api/endpoints/queue';
|
||||
import { assert } from 'tsafe';
|
||||
import z from 'zod';
|
||||
|
||||
type CanvasStagingAreaState = {
|
||||
_version: 1;
|
||||
canvasSessionId: string;
|
||||
canvasDiscardedQueueItems: number[];
|
||||
};
|
||||
const zCanvasStagingAreaState = z.object({
|
||||
_version: z.literal(1),
|
||||
canvasSessionId: z.string(),
|
||||
canvasDiscardedQueueItems: z.array(z.number().int()),
|
||||
});
|
||||
type CanvasStagingAreaState = z.infer<typeof zCanvasStagingAreaState>;
|
||||
|
||||
const INITIAL_STATE: CanvasStagingAreaState = {
|
||||
const getInitialState = (): CanvasStagingAreaState => ({
|
||||
_version: 1,
|
||||
canvasSessionId: getPrefixedId('canvas'),
|
||||
canvasDiscardedQueueItems: [],
|
||||
};
|
||||
});
|
||||
|
||||
const getInitialState = (): CanvasStagingAreaState => deepClone(INITIAL_STATE);
|
||||
|
||||
export const canvasSessionSlice = createSlice({
|
||||
const slice = createSlice({
|
||||
name: 'canvasSession',
|
||||
initialState: getInitialState(),
|
||||
reducers: {
|
||||
@@ -48,26 +50,26 @@ export const canvasSessionSlice = createSlice({
|
||||
},
|
||||
});
|
||||
|
||||
export const { canvasSessionReset, canvasQueueItemDiscarded } = canvasSessionSlice.actions;
|
||||
export const { canvasSessionReset, canvasQueueItemDiscarded } = slice.actions;
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
const migrate = (state: any): any => {
|
||||
if (!('_version' in state)) {
|
||||
state._version = 1;
|
||||
state.canvasSessionId = state.canvasSessionId ?? getPrefixedId('canvas');
|
||||
}
|
||||
export const canvasSessionSliceConfig: SliceConfig<typeof slice> = {
|
||||
slice,
|
||||
schema: zCanvasStagingAreaState,
|
||||
getInitialState,
|
||||
persistConfig: {
|
||||
migrate: (state) => {
|
||||
assert(isPlainObject(state));
|
||||
if (!('_version' in state)) {
|
||||
state._version = 1;
|
||||
state.canvasSessionId = state.canvasSessionId ?? getPrefixedId('canvas');
|
||||
}
|
||||
|
||||
return state;
|
||||
return zCanvasStagingAreaState.parse(state);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const canvasStagingAreaPersistConfig: PersistConfig<CanvasStagingAreaState> = {
|
||||
name: canvasSessionSlice.name,
|
||||
initialState: getInitialState(),
|
||||
migrate,
|
||||
persistDenylist: [],
|
||||
};
|
||||
|
||||
export const selectCanvasSessionSlice = (s: RootState) => s[canvasSessionSlice.name];
|
||||
export const selectCanvasSessionSlice = (s: RootState) => s[slice.name];
|
||||
export const selectCanvasSessionId = createSelector(selectCanvasSessionSlice, ({ canvasSessionId }) => canvasSessionId);
|
||||
|
||||
const selectDiscardedItems = createSelector(
|
||||
|
||||
@@ -166,7 +166,7 @@ const _zFilterConfig = z.discriminatedUnion('type', [
|
||||
]);
|
||||
export type FilterConfig = z.infer<typeof _zFilterConfig>;
|
||||
|
||||
const zFilterType = z.enum([
|
||||
export const zFilterType = z.enum([
|
||||
'adjust_image',
|
||||
'canny_edge_detection',
|
||||
'color_map',
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
import type { PersistConfig, RootState } from 'app/store/store';
|
||||
import { deepClone } from 'common/util/deepClone';
|
||||
import type { RootState } from 'app/store/store';
|
||||
import type { SliceConfig } from 'app/store/types';
|
||||
import { paramsReset } from 'features/controlLayers/store/paramsSlice';
|
||||
import type { LoRA } from 'features/controlLayers/store/types';
|
||||
import { type LoRA, zLoRA } from 'features/controlLayers/store/types';
|
||||
import { zModelIdentifierField } from 'features/nodes/types/common';
|
||||
import type { LoRAModelConfig } from 'services/api/types';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import z from 'zod';
|
||||
|
||||
type LoRAsState = {
|
||||
loras: LoRA[];
|
||||
};
|
||||
const zLoRAsState = z.object({
|
||||
loras: z.array(zLoRA),
|
||||
});
|
||||
type LoRAsState = z.infer<typeof zLoRAsState>;
|
||||
|
||||
const defaultLoRAConfig: Pick<LoRA, 'weight' | 'isEnabled'> = {
|
||||
weight: 0.75,
|
||||
isEnabled: true,
|
||||
};
|
||||
|
||||
const initialState: LoRAsState = {
|
||||
const getInitialState = (): LoRAsState => ({
|
||||
loras: [],
|
||||
};
|
||||
});
|
||||
|
||||
const selectLoRA = (state: LoRAsState, id: string) => state.loras.find((lora) => lora.id === id);
|
||||
|
||||
export const lorasSlice = createSlice({
|
||||
const slice = createSlice({
|
||||
name: 'loras',
|
||||
initialState,
|
||||
initialState: getInitialState(),
|
||||
reducers: {
|
||||
loraAdded: {
|
||||
reducer: (state, action: PayloadAction<{ model: LoRAModelConfig; id: string }>) => {
|
||||
@@ -66,24 +68,21 @@ export const lorasSlice = createSlice({
|
||||
extraReducers(builder) {
|
||||
builder.addCase(paramsReset, () => {
|
||||
// When a new session is requested, clear all LoRAs
|
||||
return deepClone(initialState);
|
||||
return getInitialState();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { loraAdded, loraRecalled, loraDeleted, loraWeightChanged, loraIsEnabledChanged, loraAllDeleted } =
|
||||
lorasSlice.actions;
|
||||
slice.actions;
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
const migrate = (state: any): any => {
|
||||
return state;
|
||||
};
|
||||
|
||||
export const lorasPersistConfig: PersistConfig<LoRAsState> = {
|
||||
name: lorasSlice.name,
|
||||
initialState,
|
||||
migrate,
|
||||
persistDenylist: [],
|
||||
export const lorasSliceConfig: SliceConfig<typeof slice> = {
|
||||
slice,
|
||||
schema: zLoRAsState,
|
||||
getInitialState,
|
||||
persistConfig: {
|
||||
migrate: (state) => zLoRAsState.parse(state),
|
||||
},
|
||||
};
|
||||
|
||||
export const selectLoRAsSlice = (state: RootState) => state.loras;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PayloadAction, Selector } from '@reduxjs/toolkit';
|
||||
import { createSelector, createSlice } from '@reduxjs/toolkit';
|
||||
import type { PersistConfig, RootState } from 'app/store/store';
|
||||
import type { RootState } from 'app/store/store';
|
||||
import type { SliceConfig } from 'app/store/types';
|
||||
import { deepClone } from 'common/util/deepClone';
|
||||
import { roundDownToMultiple, roundToMultiple } from 'common/util/roundDownToMultiple';
|
||||
import { clamp } from 'es-toolkit/compat';
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
isChatGPT4oAspectRatioID,
|
||||
isFluxKontextAspectRatioID,
|
||||
isImagenAspectRatioID,
|
||||
zParamsState,
|
||||
} from 'features/controlLayers/store/types';
|
||||
import { calculateNewSize } from 'features/controlLayers/util/getScaledBoundingBoxDimensions';
|
||||
import { CLIP_SKIP_MAP } from 'features/parameters/types/constants';
|
||||
@@ -40,7 +42,7 @@ import { getGridSize, getIsSizeOptimal, getOptimalDimension } from 'features/par
|
||||
import { modelConfigsAdapterSelectors, selectModelConfigsQuery } from 'services/api/endpoints/models';
|
||||
import { isNonRefinerMainModelConfig } from 'services/api/types';
|
||||
|
||||
export const paramsSlice = createSlice({
|
||||
const slice = createSlice({
|
||||
name: 'params',
|
||||
initialState: getInitialParamsState(),
|
||||
reducers: {
|
||||
@@ -92,7 +94,12 @@ export const paramsSlice = createSlice({
|
||||
state,
|
||||
action: PayloadAction<{ model: ParameterModel | null; previousModel?: ParameterModel | null }>
|
||||
) => {
|
||||
const { model, previousModel } = action.payload;
|
||||
const { previousModel } = action.payload;
|
||||
const result = zParamsState.shape.model.safeParse(action.payload.model);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
const model = result.data;
|
||||
state.model = model;
|
||||
|
||||
// If the model base changes (e.g. SD1.5 -> SDXL), we need to change a few things
|
||||
@@ -111,25 +118,53 @@ export const paramsSlice = createSlice({
|
||||
},
|
||||
vaeSelected: (state, action: PayloadAction<ParameterVAEModel | null>) => {
|
||||
// null is a valid VAE!
|
||||
state.vae = action.payload;
|
||||
const result = zParamsState.shape.vae.safeParse(action.payload);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
state.vae = result.data;
|
||||
},
|
||||
fluxVAESelected: (state, action: PayloadAction<ParameterVAEModel | null>) => {
|
||||
state.fluxVAE = action.payload;
|
||||
const result = zParamsState.shape.fluxVAE.safeParse(action.payload);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
state.fluxVAE = result.data;
|
||||
},
|
||||
t5EncoderModelSelected: (state, action: PayloadAction<ParameterT5EncoderModel | null>) => {
|
||||
state.t5EncoderModel = action.payload;
|
||||
const result = zParamsState.shape.t5EncoderModel.safeParse(action.payload);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
state.t5EncoderModel = result.data;
|
||||
},
|
||||
controlLoRAModelSelected: (state, action: PayloadAction<ParameterControlLoRAModel | null>) => {
|
||||
state.controlLora = action.payload;
|
||||
const result = zParamsState.shape.controlLora.safeParse(action.payload);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
state.controlLora = result.data;
|
||||
},
|
||||
clipEmbedModelSelected: (state, action: PayloadAction<ParameterCLIPEmbedModel | null>) => {
|
||||
state.clipEmbedModel = action.payload;
|
||||
const result = zParamsState.shape.clipEmbedModel.safeParse(action.payload);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
state.clipEmbedModel = result.data;
|
||||
},
|
||||
clipLEmbedModelSelected: (state, action: PayloadAction<ParameterCLIPLEmbedModel | null>) => {
|
||||
state.clipLEmbedModel = action.payload;
|
||||
const result = zParamsState.shape.clipLEmbedModel.safeParse(action.payload);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
state.clipLEmbedModel = result.data;
|
||||
},
|
||||
clipGEmbedModelSelected: (state, action: PayloadAction<ParameterCLIPGEmbedModel | null>) => {
|
||||
state.clipGEmbedModel = action.payload;
|
||||
const result = zParamsState.shape.clipGEmbedModel.safeParse(action.payload);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
state.clipGEmbedModel = result.data;
|
||||
},
|
||||
vaePrecisionChanged: (state, action: PayloadAction<ParameterPrecision>) => {
|
||||
state.vaePrecision = action.payload;
|
||||
@@ -156,7 +191,11 @@ export const paramsSlice = createSlice({
|
||||
state.shouldConcatPrompts = action.payload;
|
||||
},
|
||||
refinerModelChanged: (state, action: PayloadAction<ParameterSDXLRefinerModel | null>) => {
|
||||
state.refinerModel = action.payload;
|
||||
const result = zParamsState.shape.refinerModel.safeParse(action.payload);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
state.refinerModel = result.data;
|
||||
},
|
||||
setRefinerSteps: (state, action: PayloadAction<number>) => {
|
||||
state.refinerSteps = action.payload;
|
||||
@@ -397,18 +436,15 @@ export const {
|
||||
syncedToOptimalDimension,
|
||||
|
||||
paramsReset,
|
||||
} = paramsSlice.actions;
|
||||
} = slice.actions;
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
const migrate = (state: any): any => {
|
||||
return state;
|
||||
};
|
||||
|
||||
export const paramsPersistConfig: PersistConfig<ParamsState> = {
|
||||
name: paramsSlice.name,
|
||||
initialState: getInitialParamsState(),
|
||||
migrate,
|
||||
persistDenylist: [],
|
||||
export const paramsSliceConfig: SliceConfig<typeof slice> = {
|
||||
slice,
|
||||
schema: zParamsState,
|
||||
getInitialState: getInitialParamsState,
|
||||
persistConfig: {
|
||||
migrate: (state) => zParamsState.parse(state),
|
||||
},
|
||||
};
|
||||
|
||||
export const selectParamsSlice = (state: RootState) => state.params;
|
||||
|
||||
@@ -2,7 +2,8 @@ import { objectEquals } from '@observ33r/object-equals';
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSelector, createSlice } from '@reduxjs/toolkit';
|
||||
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||
import type { PersistConfig, RootState } from 'app/store/store';
|
||||
import type { RootState } from 'app/store/store';
|
||||
import type { SliceConfig } from 'app/store/types';
|
||||
import { clamp } from 'es-toolkit/compat';
|
||||
import { getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import type { FLUXReduxImageInfluence, RefImagesState } from 'features/controlLayers/store/types';
|
||||
@@ -18,7 +19,7 @@ import { assert } from 'tsafe';
|
||||
import type { PartialDeep } from 'type-fest';
|
||||
|
||||
import type { CLIPVisionModelV2, IPMethodV2, RefImageState } from './types';
|
||||
import { getInitialRefImagesState, isFLUXReduxConfig, isIPAdapterConfig } from './types';
|
||||
import { getInitialRefImagesState, isFLUXReduxConfig, isIPAdapterConfig, zRefImagesState } from './types';
|
||||
import {
|
||||
getReferenceImageState,
|
||||
imageDTOToImageWithDims,
|
||||
@@ -36,7 +37,7 @@ type PayloadActionWithId<T = void> = T extends void
|
||||
} & T
|
||||
>;
|
||||
|
||||
export const refImagesSlice = createSlice({
|
||||
const slice = createSlice({
|
||||
name: 'refImages',
|
||||
initialState: getInitialRefImagesState(),
|
||||
reducers: {
|
||||
@@ -263,18 +264,16 @@ export const {
|
||||
refImageFLUXReduxImageInfluenceChanged,
|
||||
refImageIsEnabledToggled,
|
||||
refImagesRecalled,
|
||||
} = refImagesSlice.actions;
|
||||
} = slice.actions;
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
const migrate = (state: any): any => {
|
||||
return state;
|
||||
};
|
||||
|
||||
export const refImagesPersistConfig: PersistConfig<RefImagesState> = {
|
||||
name: refImagesSlice.name,
|
||||
initialState: getInitialRefImagesState(),
|
||||
migrate,
|
||||
persistDenylist: ['selectedEntityId', 'isPanelOpen'],
|
||||
export const refImagesSliceConfig: SliceConfig<typeof slice> = {
|
||||
slice,
|
||||
schema: zRefImagesState,
|
||||
getInitialState: getInitialRefImagesState,
|
||||
persistConfig: {
|
||||
migrate: (state) => zRefImagesState.parse(state),
|
||||
persistDenylist: ['selectedEntityId', 'isPanelOpen'],
|
||||
},
|
||||
};
|
||||
|
||||
export const selectRefImagesSlice = (state: RootState) => state.refImages;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { deepClone } from 'common/util/deepClone';
|
||||
import type { CanvasEntityAdapter } from 'features/controlLayers/konva/CanvasEntity/types';
|
||||
import { fetchModelConfigByIdentifier } from 'features/metadata/util/modelFetchingHelpers';
|
||||
import type { ProgressImage } from 'features/nodes/types/common';
|
||||
import { zMainModelBase, zModelIdentifierField } from 'features/nodes/types/common';
|
||||
import type { ParameterLoRAModel } from 'features/parameters/types/parameterSchemas';
|
||||
import {
|
||||
zParameterCanvasCoherenceMode,
|
||||
zParameterCFGRescaleMultiplier,
|
||||
@@ -29,33 +26,17 @@ import {
|
||||
zParameterT5EncoderModel,
|
||||
zParameterVAEModel,
|
||||
} from 'features/parameters/types/parameterSchemas';
|
||||
import { getImageDTOSafe } from 'services/api/endpoints/images';
|
||||
import type { JsonObject } from 'type-fest';
|
||||
import { z } from 'zod';
|
||||
|
||||
const zId = z.string().min(1);
|
||||
const zName = z.string().min(1).nullable();
|
||||
|
||||
const zServerValidatedModelIdentifierField = zModelIdentifierField.refine(async (modelIdentifier) => {
|
||||
try {
|
||||
await fetchModelConfigByIdentifier(modelIdentifier);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
export const zImageWithDims = z.object({
|
||||
image_name: z.string(),
|
||||
width: z.number().int().positive(),
|
||||
height: z.number().int().positive(),
|
||||
});
|
||||
|
||||
const zImageWithDims = z
|
||||
.object({
|
||||
image_name: z.string(),
|
||||
width: z.number().int().positive(),
|
||||
height: z.number().int().positive(),
|
||||
})
|
||||
.refine(async (v) => {
|
||||
const { image_name } = v;
|
||||
const imageDTO = await getImageDTOSafe(image_name, { forceRefetch: true });
|
||||
return imageDTO !== null;
|
||||
});
|
||||
export type ImageWithDims = z.infer<typeof zImageWithDims>;
|
||||
|
||||
const zImageWithDimsDataURL = z.object({
|
||||
@@ -253,7 +234,7 @@ export type CanvasObjectState = z.infer<typeof zCanvasObjectState>;
|
||||
const zIPAdapterConfig = z.object({
|
||||
type: z.literal('ip_adapter'),
|
||||
image: zImageWithDims.nullable(),
|
||||
model: zServerValidatedModelIdentifierField.nullable(),
|
||||
model: zModelIdentifierField.nullable(),
|
||||
weight: z.number().gte(-1).lte(2),
|
||||
beginEndStepPct: zBeginEndStepPct,
|
||||
method: zIPMethodV2,
|
||||
@@ -268,7 +249,7 @@ export type FLUXReduxImageInfluence = z.infer<typeof zFLUXReduxImageInfluence>;
|
||||
const zFLUXReduxConfig = z.object({
|
||||
type: z.literal('flux_redux'),
|
||||
image: zImageWithDims.nullable(),
|
||||
model: zServerValidatedModelIdentifierField.nullable(),
|
||||
model: zModelIdentifierField.nullable(),
|
||||
imageInfluence: zFLUXReduxImageInfluence.default('highest'),
|
||||
});
|
||||
export type FLUXReduxConfig = z.infer<typeof zFLUXReduxConfig>;
|
||||
@@ -281,14 +262,14 @@ const zChatGPT4oReferenceImageConfig = z.object({
|
||||
* But we use a model drop down to switch between different ref image types, so there needs to be a model here else
|
||||
* there will be no way to switch between ref image types.
|
||||
*/
|
||||
model: zServerValidatedModelIdentifierField.nullable(),
|
||||
model: zModelIdentifierField.nullable(),
|
||||
});
|
||||
export type ChatGPT4oReferenceImageConfig = z.infer<typeof zChatGPT4oReferenceImageConfig>;
|
||||
|
||||
const zFluxKontextReferenceImageConfig = z.object({
|
||||
type: z.literal('flux_kontext_reference_image'),
|
||||
image: zImageWithDims.nullable(),
|
||||
model: zServerValidatedModelIdentifierField.nullable(),
|
||||
model: zModelIdentifierField.nullable(),
|
||||
});
|
||||
export type FluxKontextReferenceImageConfig = z.infer<typeof zFluxKontextReferenceImageConfig>;
|
||||
|
||||
@@ -360,7 +341,7 @@ export type CanvasInpaintMaskState = z.infer<typeof zCanvasInpaintMaskState>;
|
||||
|
||||
const zControlNetConfig = z.object({
|
||||
type: z.literal('controlnet'),
|
||||
model: zServerValidatedModelIdentifierField.nullable(),
|
||||
model: zModelIdentifierField.nullable(),
|
||||
weight: z.number().gte(-1).lte(2),
|
||||
beginEndStepPct: zBeginEndStepPct,
|
||||
controlMode: zControlModeV2,
|
||||
@@ -369,7 +350,7 @@ export type ControlNetConfig = z.infer<typeof zControlNetConfig>;
|
||||
|
||||
const zT2IAdapterConfig = z.object({
|
||||
type: z.literal('t2i_adapter'),
|
||||
model: zServerValidatedModelIdentifierField.nullable(),
|
||||
model: zModelIdentifierField.nullable(),
|
||||
weight: z.number().gte(-1).lte(2),
|
||||
beginEndStepPct: zBeginEndStepPct,
|
||||
});
|
||||
@@ -378,7 +359,7 @@ export type T2IAdapterConfig = z.infer<typeof zT2IAdapterConfig>;
|
||||
const zControlLoRAConfig = z.object({
|
||||
type: z.literal('control_lora'),
|
||||
weight: z.number().gte(-1).lte(2),
|
||||
model: zServerValidatedModelIdentifierField.nullable(),
|
||||
model: zModelIdentifierField.nullable(),
|
||||
});
|
||||
export type ControlLoRAConfig = z.infer<typeof zControlLoRAConfig>;
|
||||
|
||||
@@ -424,14 +405,13 @@ export const zCanvasEntityIdentifer = z.object({
|
||||
});
|
||||
export type CanvasEntityIdentifier<T extends CanvasEntityType = CanvasEntityType> = { id: string; type: T };
|
||||
|
||||
export type LoRA = {
|
||||
id: string;
|
||||
isEnabled: boolean;
|
||||
model: ParameterLoRAModel;
|
||||
weight: number;
|
||||
};
|
||||
|
||||
export type EphemeralProgressImage = { sessionId: string; image: ProgressImage };
|
||||
export const zLoRA = z.object({
|
||||
id: z.string(),
|
||||
isEnabled: z.boolean(),
|
||||
model: zModelIdentifierField,
|
||||
weight: z.number().gte(-1).lte(2),
|
||||
});
|
||||
export type LoRA = z.infer<typeof zLoRA>;
|
||||
|
||||
export const zAspectRatioID = z.enum(['Free', '21:9', '16:9', '3:2', '4:3', '1:1', '3:4', '2:3', '9:16', '9:21']);
|
||||
export type AspectRatioID = z.infer<typeof zAspectRatioID>;
|
||||
@@ -522,62 +502,108 @@ const zDimensionsState = z.object({
|
||||
aspectRatio: zAspectRatioConfig,
|
||||
});
|
||||
|
||||
const zParamsState = z.object({
|
||||
maskBlur: z.number().default(16),
|
||||
maskBlurMethod: zParameterMaskBlurMethod.default('box'),
|
||||
canvasCoherenceMode: zParameterCanvasCoherenceMode.default('Gaussian Blur'),
|
||||
canvasCoherenceMinDenoise: zParameterStrength.default(0),
|
||||
canvasCoherenceEdgeSize: z.number().default(16),
|
||||
infillMethod: z.string().default('lama'),
|
||||
infillTileSize: z.number().default(32),
|
||||
infillPatchmatchDownscaleSize: z.number().default(1),
|
||||
infillColorValue: zRgbaColor.default({ r: 0, g: 0, b: 0, a: 1 }),
|
||||
cfgScale: zParameterCFGScale.default(7.5),
|
||||
cfgRescaleMultiplier: zParameterCFGRescaleMultiplier.default(0),
|
||||
guidance: zParameterGuidance.default(4),
|
||||
img2imgStrength: zParameterStrength.default(0.75),
|
||||
optimizedDenoisingEnabled: z.boolean().default(true),
|
||||
iterations: z.number().default(1),
|
||||
scheduler: zParameterScheduler.default('dpmpp_3m_k'),
|
||||
upscaleScheduler: zParameterScheduler.default('kdpm_2'),
|
||||
upscaleCfgScale: zParameterCFGScale.default(2),
|
||||
seed: zParameterSeed.default(0),
|
||||
shouldRandomizeSeed: z.boolean().default(true),
|
||||
steps: zParameterSteps.default(30),
|
||||
model: zParameterModel.nullable().default(null),
|
||||
vae: zParameterVAEModel.nullable().default(null),
|
||||
vaePrecision: zParameterPrecision.default('fp32'),
|
||||
fluxVAE: zParameterVAEModel.nullable().default(null),
|
||||
seamlessXAxis: z.boolean().default(false),
|
||||
seamlessYAxis: z.boolean().default(false),
|
||||
clipSkip: z.number().default(0),
|
||||
shouldUseCpuNoise: z.boolean().default(true),
|
||||
positivePrompt: zParameterPositivePrompt.default(''),
|
||||
// Negative prompt may be disabled, in which case it will be null
|
||||
negativePrompt: zParameterNegativePrompt.default(null),
|
||||
positivePrompt2: zParameterPositiveStylePromptSDXL.default(''),
|
||||
negativePrompt2: zParameterNegativeStylePromptSDXL.default(''),
|
||||
shouldConcatPrompts: z.boolean().default(true),
|
||||
refinerModel: zParameterSDXLRefinerModel.nullable().default(null),
|
||||
refinerSteps: z.number().default(20),
|
||||
refinerCFGScale: z.number().default(7.5),
|
||||
refinerScheduler: zParameterScheduler.default('euler'),
|
||||
refinerPositiveAestheticScore: z.number().default(6),
|
||||
refinerNegativeAestheticScore: z.number().default(2.5),
|
||||
refinerStart: z.number().default(0.8),
|
||||
t5EncoderModel: zParameterT5EncoderModel.nullable().default(null),
|
||||
clipEmbedModel: zParameterCLIPEmbedModel.nullable().default(null),
|
||||
clipLEmbedModel: zParameterCLIPLEmbedModel.nullable().default(null),
|
||||
clipGEmbedModel: zParameterCLIPGEmbedModel.nullable().default(null),
|
||||
controlLora: zParameterControlLoRAModel.nullable().default(null),
|
||||
dimensions: zDimensionsState.default({
|
||||
rect: { x: 0, y: 0, width: 512, height: 512 },
|
||||
aspectRatio: DEFAULT_ASPECT_RATIO_CONFIG,
|
||||
}),
|
||||
export const zParamsState = z.object({
|
||||
maskBlur: z.number(),
|
||||
maskBlurMethod: zParameterMaskBlurMethod,
|
||||
canvasCoherenceMode: zParameterCanvasCoherenceMode,
|
||||
canvasCoherenceMinDenoise: zParameterStrength,
|
||||
canvasCoherenceEdgeSize: z.number(),
|
||||
infillMethod: z.string(),
|
||||
infillTileSize: z.number(),
|
||||
infillPatchmatchDownscaleSize: z.number(),
|
||||
infillColorValue: zRgbaColor,
|
||||
cfgScale: zParameterCFGScale,
|
||||
cfgRescaleMultiplier: zParameterCFGRescaleMultiplier,
|
||||
guidance: zParameterGuidance,
|
||||
img2imgStrength: zParameterStrength,
|
||||
optimizedDenoisingEnabled: z.boolean(),
|
||||
iterations: z.number(),
|
||||
scheduler: zParameterScheduler,
|
||||
upscaleScheduler: zParameterScheduler,
|
||||
upscaleCfgScale: zParameterCFGScale,
|
||||
seed: zParameterSeed,
|
||||
shouldRandomizeSeed: z.boolean(),
|
||||
steps: zParameterSteps,
|
||||
model: zParameterModel.nullable(),
|
||||
vae: zParameterVAEModel.nullable(),
|
||||
vaePrecision: zParameterPrecision,
|
||||
fluxVAE: zParameterVAEModel.nullable(),
|
||||
seamlessXAxis: z.boolean(),
|
||||
seamlessYAxis: z.boolean(),
|
||||
clipSkip: z.number(),
|
||||
shouldUseCpuNoise: z.boolean(),
|
||||
positivePrompt: zParameterPositivePrompt,
|
||||
negativePrompt: zParameterNegativePrompt,
|
||||
positivePrompt2: zParameterPositiveStylePromptSDXL,
|
||||
negativePrompt2: zParameterNegativeStylePromptSDXL,
|
||||
shouldConcatPrompts: z.boolean(),
|
||||
refinerModel: zParameterSDXLRefinerModel.nullable(),
|
||||
refinerSteps: z.number(),
|
||||
refinerCFGScale: z.number(),
|
||||
refinerScheduler: zParameterScheduler,
|
||||
refinerPositiveAestheticScore: z.number(),
|
||||
refinerNegativeAestheticScore: z.number(),
|
||||
refinerStart: z.number(),
|
||||
t5EncoderModel: zParameterT5EncoderModel.nullable(),
|
||||
clipEmbedModel: zParameterCLIPEmbedModel.nullable(),
|
||||
clipLEmbedModel: zParameterCLIPLEmbedModel.nullable(),
|
||||
clipGEmbedModel: zParameterCLIPGEmbedModel.nullable(),
|
||||
controlLora: zParameterControlLoRAModel.nullable(),
|
||||
dimensions: zDimensionsState,
|
||||
});
|
||||
export type ParamsState = z.infer<typeof zParamsState>;
|
||||
const INITIAL_PARAMS_STATE = zParamsState.parse({});
|
||||
export const getInitialParamsState = () => deepClone(INITIAL_PARAMS_STATE);
|
||||
export const getInitialParamsState = (): ParamsState => ({
|
||||
maskBlur: 16,
|
||||
maskBlurMethod: 'box',
|
||||
canvasCoherenceMode: 'Gaussian Blur',
|
||||
canvasCoherenceMinDenoise: 0,
|
||||
canvasCoherenceEdgeSize: 16,
|
||||
infillMethod: 'lama',
|
||||
infillTileSize: 32,
|
||||
infillPatchmatchDownscaleSize: 1,
|
||||
infillColorValue: { r: 0, g: 0, b: 0, a: 1 },
|
||||
cfgScale: 7.5,
|
||||
cfgRescaleMultiplier: 0,
|
||||
guidance: 4,
|
||||
img2imgStrength: 0.75,
|
||||
optimizedDenoisingEnabled: true,
|
||||
iterations: 1,
|
||||
scheduler: 'dpmpp_3m_k',
|
||||
upscaleScheduler: 'kdpm_2',
|
||||
upscaleCfgScale: 2,
|
||||
seed: 0,
|
||||
shouldRandomizeSeed: true,
|
||||
steps: 30,
|
||||
model: null,
|
||||
vae: null,
|
||||
vaePrecision: 'fp32',
|
||||
fluxVAE: null,
|
||||
seamlessXAxis: false,
|
||||
seamlessYAxis: false,
|
||||
clipSkip: 0,
|
||||
shouldUseCpuNoise: true,
|
||||
positivePrompt: '',
|
||||
negativePrompt: null,
|
||||
positivePrompt2: '',
|
||||
negativePrompt2: '',
|
||||
shouldConcatPrompts: true,
|
||||
refinerModel: null,
|
||||
refinerSteps: 20,
|
||||
refinerCFGScale: 7.5,
|
||||
refinerScheduler: 'euler',
|
||||
refinerPositiveAestheticScore: 6,
|
||||
refinerNegativeAestheticScore: 2.5,
|
||||
refinerStart: 0.8,
|
||||
t5EncoderModel: null,
|
||||
clipEmbedModel: null,
|
||||
clipLEmbedModel: null,
|
||||
clipGEmbedModel: null,
|
||||
controlLora: null,
|
||||
dimensions: {
|
||||
rect: { x: 0, y: 0, width: 512, height: 512 },
|
||||
aspectRatio: deepClone(DEFAULT_ASPECT_RATIO_CONFIG),
|
||||
},
|
||||
});
|
||||
|
||||
const zInpaintMasks = z.object({
|
||||
isHidden: z.boolean(),
|
||||
@@ -595,38 +621,45 @@ const zRegionalGuidance = z.object({
|
||||
isHidden: z.boolean(),
|
||||
entities: z.array(zCanvasRegionalGuidanceState),
|
||||
});
|
||||
const zCanvasState = z.object({
|
||||
_version: z.literal(3).default(3),
|
||||
selectedEntityIdentifier: zCanvasEntityIdentifer.nullable().default(null),
|
||||
bookmarkedEntityIdentifier: zCanvasEntityIdentifer.nullable().default(null),
|
||||
inpaintMasks: zInpaintMasks.default({ isHidden: false, entities: [] }),
|
||||
rasterLayers: zRasterLayers.default({ isHidden: false, entities: [] }),
|
||||
controlLayers: zControlLayers.default({ isHidden: false, entities: [] }),
|
||||
regionalGuidance: zRegionalGuidance.default({ isHidden: false, entities: [] }),
|
||||
bbox: zBboxState.default({
|
||||
export const zCanvasState = z.object({
|
||||
_version: z.literal(3),
|
||||
selectedEntityIdentifier: zCanvasEntityIdentifer.nullable(),
|
||||
bookmarkedEntityIdentifier: zCanvasEntityIdentifer.nullable(),
|
||||
inpaintMasks: zInpaintMasks,
|
||||
rasterLayers: zRasterLayers,
|
||||
controlLayers: zControlLayers,
|
||||
regionalGuidance: zRegionalGuidance,
|
||||
bbox: zBboxState,
|
||||
});
|
||||
export type CanvasState = z.infer<typeof zCanvasState>;
|
||||
export const getInitialCanvasState = (): CanvasState => ({
|
||||
_version: 3,
|
||||
selectedEntityIdentifier: null,
|
||||
bookmarkedEntityIdentifier: null,
|
||||
inpaintMasks: { isHidden: false, entities: [] },
|
||||
rasterLayers: { isHidden: false, entities: [] },
|
||||
controlLayers: { isHidden: false, entities: [] },
|
||||
regionalGuidance: { isHidden: false, entities: [] },
|
||||
bbox: {
|
||||
rect: { x: 0, y: 0, width: 512, height: 512 },
|
||||
aspectRatio: DEFAULT_ASPECT_RATIO_CONFIG,
|
||||
aspectRatio: deepClone(DEFAULT_ASPECT_RATIO_CONFIG),
|
||||
scaleMethod: 'auto',
|
||||
scaledSize: { width: 512, height: 512 },
|
||||
modelBase: 'sd-1',
|
||||
}),
|
||||
},
|
||||
});
|
||||
export type CanvasState = z.infer<typeof zCanvasState>;
|
||||
|
||||
const zRefImagesState = z.object({
|
||||
selectedEntityId: z.string().nullable().default(null),
|
||||
isPanelOpen: z.boolean().default(false),
|
||||
entities: z.array(zRefImageState).default(() => []),
|
||||
export const zRefImagesState = z.object({
|
||||
selectedEntityId: z.string().nullable(),
|
||||
isPanelOpen: z.boolean(),
|
||||
entities: z.array(zRefImageState),
|
||||
});
|
||||
export type RefImagesState = z.infer<typeof zRefImagesState>;
|
||||
const INITIAL_REF_IMAGES_STATE = zRefImagesState.parse({});
|
||||
export const getInitialRefImagesState = () => deepClone(INITIAL_REF_IMAGES_STATE);
|
||||
|
||||
/**
|
||||
* Gets a fresh canvas initial state with no references in memory to existing objects.
|
||||
*/
|
||||
const CANVAS_INITIAL_STATE = zCanvasState.parse({});
|
||||
export const getInitialCanvasState = () => deepClone(CANVAS_INITIAL_STATE);
|
||||
export const getInitialRefImagesState = (): RefImagesState => ({
|
||||
selectedEntityId: null,
|
||||
isPanelOpen: false,
|
||||
entities: [],
|
||||
});
|
||||
|
||||
export const zCanvasReferenceImageState_OLD = zCanvasEntityBase.extend({
|
||||
type: z.literal('reference_image'),
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import type { PayloadAction, Selector } from '@reduxjs/toolkit';
|
||||
import { createSelector, createSlice } from '@reduxjs/toolkit';
|
||||
import type { PersistConfig, RootState } from 'app/store/store';
|
||||
import type { RootState } from 'app/store/store';
|
||||
import type { SliceConfig } from 'app/store/types';
|
||||
import { buildZodTypeGuard } from 'common/util/zodUtils';
|
||||
import { isPlainObject } from 'es-toolkit';
|
||||
import { assert } from 'tsafe';
|
||||
import { z } from 'zod';
|
||||
|
||||
const zSeedBehaviour = z.enum(['PER_ITERATION', 'PER_PROMPT']);
|
||||
export const isSeedBehaviour = buildZodTypeGuard(zSeedBehaviour);
|
||||
export type SeedBehaviour = z.infer<typeof zSeedBehaviour>;
|
||||
|
||||
export interface DynamicPromptsState {
|
||||
_version: 1;
|
||||
maxPrompts: number;
|
||||
combinatorial: boolean;
|
||||
prompts: string[];
|
||||
parsingError: string | undefined | null;
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
seedBehaviour: SeedBehaviour;
|
||||
}
|
||||
const zDynamicPromptsState = z.object({
|
||||
_version: z.literal(1),
|
||||
maxPrompts: z.number().int().min(1).max(1000),
|
||||
combinatorial: z.boolean(),
|
||||
prompts: z.array(z.string()),
|
||||
parsingError: z.string().nullish(),
|
||||
isError: z.boolean(),
|
||||
isLoading: z.boolean(),
|
||||
seedBehaviour: zSeedBehaviour,
|
||||
});
|
||||
export type DynamicPromptsState = z.infer<typeof zDynamicPromptsState>;
|
||||
|
||||
const initialDynamicPromptsState: DynamicPromptsState = {
|
||||
const getInitialState = (): DynamicPromptsState => ({
|
||||
_version: 1,
|
||||
maxPrompts: 100,
|
||||
combinatorial: true,
|
||||
@@ -28,11 +32,11 @@ const initialDynamicPromptsState: DynamicPromptsState = {
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
seedBehaviour: 'PER_ITERATION',
|
||||
};
|
||||
});
|
||||
|
||||
export const dynamicPromptsSlice = createSlice({
|
||||
const slice = createSlice({
|
||||
name: 'dynamicPrompts',
|
||||
initialState: initialDynamicPromptsState,
|
||||
initialState: getInitialState(),
|
||||
reducers: {
|
||||
maxPromptsChanged: (state, action: PayloadAction<number>) => {
|
||||
state.maxPrompts = action.payload;
|
||||
@@ -63,21 +67,22 @@ export const {
|
||||
isErrorChanged,
|
||||
isLoadingChanged,
|
||||
seedBehaviourChanged,
|
||||
} = dynamicPromptsSlice.actions;
|
||||
} = slice.actions;
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
const migrateDynamicPromptsState = (state: any): any => {
|
||||
if (!('_version' in state)) {
|
||||
state._version = 1;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
export const dynamicPromptsPersistConfig: PersistConfig<DynamicPromptsState> = {
|
||||
name: dynamicPromptsSlice.name,
|
||||
initialState: initialDynamicPromptsState,
|
||||
migrate: migrateDynamicPromptsState,
|
||||
persistDenylist: ['prompts'],
|
||||
export const dynamicPromptsSliceConfig: SliceConfig<typeof slice> = {
|
||||
slice,
|
||||
schema: zDynamicPromptsState,
|
||||
getInitialState,
|
||||
persistConfig: {
|
||||
migrate: (state) => {
|
||||
assert(isPlainObject(state));
|
||||
if (!('_version' in state)) {
|
||||
state._version = 1;
|
||||
}
|
||||
return zDynamicPromptsState.parse(state);
|
||||
},
|
||||
persistDenylist: ['prompts', 'parsingError', 'isError', 'isLoading'],
|
||||
},
|
||||
};
|
||||
|
||||
export const selectDynamicPromptsSlice = (state: RootState) => state.dynamicPrompts;
|
||||
|
||||
@@ -59,7 +59,7 @@ export const BoardsPanel = memo(() => {
|
||||
onClick={collapsibleApi.toggle}
|
||||
leftIcon={isCollapsed ? <PiCaretDownBold /> : <PiCaretUpBold />}
|
||||
>
|
||||
Boards
|
||||
{t('boards.boards')}
|
||||
</Button>
|
||||
</Flex>
|
||||
<Flex>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MenuItem } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { imageDTOToImageWithDims } from 'features/controlLayers/store/util';
|
||||
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
|
||||
import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice';
|
||||
import { toast } from 'features/toast/toast';
|
||||
@@ -14,7 +15,7 @@ export const ImageMenuItemSendToUpscale = memo(() => {
|
||||
const imageDTO = useImageDTOContext();
|
||||
|
||||
const handleSendToCanvas = useCallback(() => {
|
||||
dispatch(upscaleInitialImageChanged(imageDTO));
|
||||
dispatch(upscaleInitialImageChanged(imageDTOToImageWithDims(imageDTO)));
|
||||
navigationApi.switchToTab('upscaling');
|
||||
toast({
|
||||
id: 'SENT_TO_CANVAS',
|
||||
|
||||
@@ -28,7 +28,7 @@ export const ImageMenuItemUseAsRefImage = memo(() => {
|
||||
|
||||
return (
|
||||
<MenuItem icon={<PiImageBold />} onClickCapture={onClickNewGlobalReferenceImageFromImage}>
|
||||
Use as Reference Image
|
||||
{t('controlLayers.useAsReferenceImage')}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ export const CompareToolbar = memo(() => {
|
||||
useRegisteredHotkeys({ id: 'nextComparisonMode', category: 'viewer', callback: nextMode, dependencies: [nextMode] });
|
||||
|
||||
return (
|
||||
<Flex w="full" px={2} gap={2} bg="base.750" borderTopRadius="base" h={12}>
|
||||
<Flex w="full" justifyContent="center" h={8}>
|
||||
<Flex flex={1} justifyContent="center">
|
||||
<Flex marginInlineEnd="auto" alignItems="center">
|
||||
<IconButton
|
||||
@@ -85,7 +85,7 @@ export const CompareToolbar = memo(() => {
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex flex={1} justifyContent="center">
|
||||
<ButtonGroup variant="outline" alignItems="center">
|
||||
<ButtonGroup size="sm" variant="outline" alignItems="center">
|
||||
<Button
|
||||
flexShrink={0}
|
||||
onClick={setComparisonModeSlider}
|
||||
@@ -117,6 +117,7 @@ export const CompareToolbar = memo(() => {
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
px={2}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { ProgressImage } from 'features/gallery/components/ImageViewer/ProgressImage';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { ProgressIndicator } from './ProgressIndicator';
|
||||
|
||||
export const GenerationProgressPanel = memo(() => {
|
||||
return (
|
||||
<Flex position="relative" flexDir="column" w="full" h="full" overflow="hidden">
|
||||
<ProgressImage />
|
||||
<ProgressIndicator position="absolute" top={6} right={6} size={8} />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
GenerationProgressPanel.displayName = 'GenerationProgressPanel';
|
||||
@@ -1,32 +1,36 @@
|
||||
import { Box, Flex } from '@invoke-ai/ui-library';
|
||||
import { Box, Divider, Flex } from '@invoke-ai/ui-library';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import type { ComparisonProps } from 'features/gallery/components/ImageViewer/common';
|
||||
import { debounce } from 'es-toolkit';
|
||||
import type { ComparisonWrapperProps } from 'features/gallery/components/ImageViewer/common';
|
||||
import { selectImageToCompare } from 'features/gallery/components/ImageViewer/common';
|
||||
import { CompareToolbar } from 'features/gallery/components/ImageViewer/CompareToolbar';
|
||||
import { ImageComparisonDroppable } from 'features/gallery/components/ImageViewer/ImageComparisonDroppable';
|
||||
import { ImageComparisonHover } from 'features/gallery/components/ImageViewer/ImageComparisonHover';
|
||||
import { ImageComparisonSideBySide } from 'features/gallery/components/ImageViewer/ImageComparisonSideBySide';
|
||||
import { ImageComparisonSlider } from 'features/gallery/components/ImageViewer/ImageComparisonSlider';
|
||||
import { selectComparisonMode } from 'features/gallery/store/gallerySelectors';
|
||||
import { memo } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
import { selectComparisonMode, selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
|
||||
import { memo, useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useImageDTO } from 'services/api/endpoints/images';
|
||||
import type { Equals } from 'tsafe';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
const ImageComparisonContent = memo(({ firstImage, secondImage, containerDims }: ComparisonProps) => {
|
||||
const ImageComparisonContent = memo(({ firstImage, secondImage, rect }: ComparisonWrapperProps) => {
|
||||
const comparisonMode = useAppSelector(selectComparisonMode);
|
||||
|
||||
if (!firstImage || !secondImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (comparisonMode === 'slider') {
|
||||
return <ImageComparisonSlider firstImage={firstImage} secondImage={secondImage} containerDims={containerDims} />;
|
||||
return <ImageComparisonSlider firstImage={firstImage} secondImage={secondImage} rect={rect} />;
|
||||
}
|
||||
|
||||
if (comparisonMode === 'side-by-side') {
|
||||
return (
|
||||
<ImageComparisonSideBySide firstImage={firstImage} secondImage={secondImage} containerDims={containerDims} />
|
||||
);
|
||||
return <ImageComparisonSideBySide firstImage={firstImage} secondImage={secondImage} rect={rect} />;
|
||||
}
|
||||
|
||||
if (comparisonMode === 'hover') {
|
||||
return <ImageComparisonHover firstImage={firstImage} secondImage={secondImage} containerDims={containerDims} />;
|
||||
return <ImageComparisonHover firstImage={firstImage} secondImage={secondImage} rect={rect} />;
|
||||
}
|
||||
|
||||
assert<Equals<never, typeof comparisonMode>>(false);
|
||||
@@ -34,16 +38,51 @@ const ImageComparisonContent = memo(({ firstImage, secondImage, containerDims }:
|
||||
|
||||
ImageComparisonContent.displayName = 'ImageComparisonContent';
|
||||
|
||||
export const ImageComparison = memo(({ firstImage, secondImage }: Omit<ComparisonProps, 'containerDims'>) => {
|
||||
const [containerRef, containerDims] = useMeasure<HTMLDivElement>();
|
||||
export const ImageComparison = memo(() => {
|
||||
const lastSelectedImageName = useAppSelector(selectLastSelectedImage);
|
||||
const lastSelectedImageDTO = useImageDTO(lastSelectedImageName);
|
||||
const comparisonImageName = useAppSelector(selectImageToCompare);
|
||||
const comparisonImageDTO = useImageDTO(comparisonImageName);
|
||||
|
||||
const [rect, setRect] = useState<DOMRect | null>(null);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Ref callback runs synchronously when the DOM node is attached, ensuring we have a measurement before
|
||||
// the comparison content is rendered.
|
||||
const measureNode = useCallback((node: HTMLDivElement) => {
|
||||
if (node) {
|
||||
ref.current = node;
|
||||
const boundingRect = node.getBoundingClientRect();
|
||||
setRect(boundingRect);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const measureRect = debounce(() => {
|
||||
const boundingRect = el.getBoundingClientRect();
|
||||
setRect(boundingRect);
|
||||
}, 300);
|
||||
const observer = new ResizeObserver(measureRect);
|
||||
observer.observe(el);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" w="full" h="full" position="relative">
|
||||
<Flex flexDir="column" w="full" h="full" overflow="hidden" gap={2} position="relative">
|
||||
<CompareToolbar />
|
||||
<Box ref={containerRef} w="full" h="full" p={2} overflow="hidden">
|
||||
<ImageComparisonContent firstImage={firstImage} secondImage={secondImage} containerDims={containerDims} />
|
||||
</Box>
|
||||
<ImageComparisonDroppable />
|
||||
<Divider />
|
||||
<Flex w="full" h="full" position="relative">
|
||||
<Box ref={measureNode} w="full" h="full" overflow="hidden">
|
||||
<ImageComparisonContent firstImage={lastSelectedImageDTO} secondImage={comparisonImageDTO} rect={rect} />
|
||||
</Box>
|
||||
<ImageComparisonDroppable />
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,14 +11,16 @@ import { memo, useMemo, useRef } from 'react';
|
||||
import type { ComparisonProps } from './common';
|
||||
import { fitDimsToContainer, getSecondImageDims } from './common';
|
||||
|
||||
export const ImageComparisonHover = memo(({ firstImage, secondImage, containerDims }: ComparisonProps) => {
|
||||
export const ImageComparisonHover = memo(({ firstImage, secondImage, rect }: ComparisonProps) => {
|
||||
const comparisonFit = useAppSelector(selectComparisonFit);
|
||||
const imageContainerRef = useRef<HTMLDivElement>(null);
|
||||
const mouseOver = useBoolean(false);
|
||||
const fittedDims = useMemo<Dimensions>(
|
||||
() => fitDimsToContainer(containerDims, firstImage),
|
||||
[containerDims, firstImage]
|
||||
);
|
||||
const fittedDims = useMemo<Dimensions>(() => {
|
||||
if (!rect) {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
return fitDimsToContainer(rect, firstImage);
|
||||
}, [firstImage, rect]);
|
||||
const compareImageDims = useMemo<Dimensions>(
|
||||
() => getSecondImageDims(comparisonFit, fittedDims, firstImage, secondImage),
|
||||
[comparisonFit, fittedDims, firstImage, secondImage]
|
||||
|
||||
@@ -19,7 +19,7 @@ const HANDLE_HITBOX_PX = `${HANDLE_HITBOX}px`;
|
||||
const HANDLE_INNER_LEFT_PX = `${HANDLE_HITBOX / 2 - HANDLE_WIDTH / 2}px`;
|
||||
const HANDLE_LEFT_INITIAL_PX = `calc(${INITIAL_POS} - ${HANDLE_HITBOX / 2}px)`;
|
||||
|
||||
export const ImageComparisonSlider = memo(({ firstImage, secondImage, containerDims }: ComparisonProps) => {
|
||||
export const ImageComparisonSlider = memo(({ firstImage, secondImage, rect }: ComparisonProps) => {
|
||||
const comparisonFit = useAppSelector(selectComparisonFit);
|
||||
|
||||
// How far the handle is from the left - this will be a CSS calculation that takes into account the handle width
|
||||
@@ -33,10 +33,12 @@ export const ImageComparisonSlider = memo(({ firstImage, secondImage, containerD
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const lastMoveTimeRef = useRef<number>(0);
|
||||
|
||||
const fittedDims = useMemo<Dimensions>(
|
||||
() => fitDimsToContainer(containerDims, firstImage),
|
||||
[containerDims, firstImage]
|
||||
);
|
||||
const fittedDims = useMemo<Dimensions>(() => {
|
||||
if (!rect) {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
return fitDimsToContainer(rect, firstImage);
|
||||
}, [firstImage, rect]);
|
||||
|
||||
const compareImageDims = useMemo<Dimensions>(
|
||||
() => getSecondImageDims(comparisonFit, fittedDims, firstImage, secondImage),
|
||||
|
||||
@@ -1,42 +1,36 @@
|
||||
import { Divider, Flex } from '@invoke-ai/ui-library';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { selectImageToCompare } from 'features/gallery/components/ImageViewer/common';
|
||||
import { setComparisonImageDndTarget } from 'features/dnd/dnd';
|
||||
import { DndDropTarget } from 'features/dnd/DndDropTarget';
|
||||
import { CurrentImagePreview } from 'features/gallery/components/ImageViewer/CurrentImagePreview';
|
||||
import { ImageComparison } from 'features/gallery/components/ImageViewer/ImageComparison';
|
||||
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useImageDTO } from 'services/api/endpoints/images';
|
||||
|
||||
// type Props = {
|
||||
// closeButton?: ReactNode;
|
||||
// };
|
||||
import { ViewerToolbar } from './ViewerToolbar';
|
||||
|
||||
// const useFocusRegionOptions = {
|
||||
// focusOnMount: true,
|
||||
// };
|
||||
|
||||
// const FOCUS_REGION_STYLES: SystemStyleObject = {
|
||||
// display: 'flex',
|
||||
// width: 'full',
|
||||
// height: 'full',
|
||||
// position: 'absolute',
|
||||
// flexDirection: 'column',
|
||||
// inset: 0,
|
||||
// alignItems: 'center',
|
||||
// justifyContent: 'center',
|
||||
// overflow: 'hidden',
|
||||
// };
|
||||
const dndTargetData = setComparisonImageDndTarget.getData();
|
||||
|
||||
export const ImageViewer = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const lastSelectedImageName = useAppSelector(selectLastSelectedImage);
|
||||
const lastSelectedImageDTO = useImageDTO(lastSelectedImageName);
|
||||
const comparisonImageName = useAppSelector(selectImageToCompare);
|
||||
const comparisonImageDTO = useImageDTO(comparisonImageName);
|
||||
|
||||
if (lastSelectedImageDTO && comparisonImageDTO) {
|
||||
return <ImageComparison firstImage={lastSelectedImageDTO} secondImage={comparisonImageDTO} />;
|
||||
}
|
||||
|
||||
return <CurrentImagePreview imageDTO={lastSelectedImageDTO} />;
|
||||
return (
|
||||
<Flex flexDir="column" w="full" h="full" overflow="hidden" gap={2} position="relative">
|
||||
<ViewerToolbar />
|
||||
<Divider />
|
||||
<Flex w="full" h="full" position="relative">
|
||||
<CurrentImagePreview imageDTO={lastSelectedImageDTO} />
|
||||
<DndDropTarget
|
||||
dndTarget={setComparisonImageDndTarget}
|
||||
dndTargetData={dndTargetData}
|
||||
label={t('gallery.selectForCompare')}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
ImageViewer.displayName = 'ImageViewer';
|
||||
|
||||
@@ -1,42 +1,24 @@
|
||||
import { Divider, Flex } from '@invoke-ai/ui-library';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import type { SetComparisonImageDndTargetData } from 'features/dnd/dnd';
|
||||
import { setComparisonImageDndTarget } from 'features/dnd/dnd';
|
||||
import { DndDropTarget } from 'features/dnd/DndDropTarget';
|
||||
import { selectImageToCompare, selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { ImageViewerContextProvider } from './context';
|
||||
import { ImageComparison } from './ImageComparison';
|
||||
import { ImageViewer } from './ImageViewer';
|
||||
import { ViewerToolbar } from './ViewerToolbar';
|
||||
|
||||
const selectIsComparing = createSelector(
|
||||
[selectLastSelectedImage, selectImageToCompare],
|
||||
(lastSelectedImage, imageToCompare) => !!lastSelectedImage && !!imageToCompare
|
||||
);
|
||||
|
||||
export const ImageViewerPanel = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const lastSelectedImage = useAppSelector(selectLastSelectedImage);
|
||||
const imageToCompare = useAppSelector(selectImageToCompare);
|
||||
|
||||
// Only show drop target when we have a selected image but no comparison image yet
|
||||
const shouldShowDropTarget = lastSelectedImage && !imageToCompare;
|
||||
|
||||
const dndTargetData = useMemo<SetComparisonImageDndTargetData>(() => setComparisonImageDndTarget.getData(), []);
|
||||
const isComparing = useAppSelector(selectIsComparing);
|
||||
|
||||
return (
|
||||
<ImageViewerContextProvider>
|
||||
<Flex flexDir="column" w="full" h="full" overflow="hidden" gap={2} position="relative">
|
||||
<ViewerToolbar />
|
||||
<Divider />
|
||||
<Flex w="full" h="full" position="relative">
|
||||
<ImageViewer />
|
||||
{shouldShowDropTarget && (
|
||||
<DndDropTarget
|
||||
dndTarget={setComparisonImageDndTarget}
|
||||
dndTargetData={dndTargetData}
|
||||
label={t('gallery.selectForCompare')}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
{!isComparing && <ImageViewer />}
|
||||
{isComparing && <ImageComparison />}
|
||||
</ImageViewerContextProvider>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Badge, Flex, Image } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
import { selectSystemSlice } from 'features/system/store/systemSlice';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { PiPulseBold } from 'react-icons/pi';
|
||||
import { useIsGenerationInProgress } from 'services/api/endpoints/queue';
|
||||
import { $lastProgressImage } from 'services/events/stores';
|
||||
|
||||
const selectShouldAntialiasProgressImage = createSelector(
|
||||
selectSystemSlice,
|
||||
(system) => system.shouldAntialiasProgressImage
|
||||
);
|
||||
|
||||
export const ProgressImage = memo(() => {
|
||||
const isGenerationInProgress = useIsGenerationInProgress();
|
||||
const progressImage = useStore($lastProgressImage);
|
||||
const shouldAntialiasProgressImage = useAppSelector(selectShouldAntialiasProgressImage);
|
||||
|
||||
const sx = useMemo<SystemStyleObject>(
|
||||
() => ({
|
||||
imageRendering: shouldAntialiasProgressImage ? 'auto' : 'pixelated',
|
||||
}),
|
||||
[shouldAntialiasProgressImage]
|
||||
);
|
||||
|
||||
if (!isGenerationInProgress) {
|
||||
return (
|
||||
<Flex width="full" height="full" alignItems="center" justifyContent="center">
|
||||
<IAINoContentFallback icon={PiPulseBold} label="No Generation in Progress" />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (!progressImage) {
|
||||
return (
|
||||
<Flex width="full" height="full" position="relative" minW={0} minH={0}>
|
||||
<Badge
|
||||
position="absolute"
|
||||
top={2}
|
||||
left={2}
|
||||
color="base.300"
|
||||
borderColor="base.700"
|
||||
borderWidth={1}
|
||||
bg="base.900"
|
||||
opacity="0.8"
|
||||
fontSize="sm"
|
||||
fontWeight="semibold"
|
||||
zIndex="docked"
|
||||
pointerEvents="none"
|
||||
borderRadius="base"
|
||||
>
|
||||
Waiting for Image
|
||||
</Badge>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex width="full" height="full" alignItems="center" justifyContent="center" minW={0} minH={0}>
|
||||
<Image
|
||||
src={progressImage.dataURL}
|
||||
width={progressImage.width}
|
||||
height={progressImage.height}
|
||||
draggable={false}
|
||||
data-testid="progress-image"
|
||||
objectFit="contain"
|
||||
maxWidth="full"
|
||||
maxHeight="full"
|
||||
borderRadius="base"
|
||||
sx={sx}
|
||||
minH={0}
|
||||
minW={0}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
ProgressImage.displayName = 'ProgressImage';
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { CircularProgressProps, SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { CircularProgress, Tooltip } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { memo } from 'react';
|
||||
import { useIsGenerationInProgress } from 'services/api/endpoints/queue';
|
||||
import { $lastProgressEvent, formatProgressMessage } from 'services/events/stores';
|
||||
|
||||
const circleStyles: SystemStyleObject = {
|
||||
circle: {
|
||||
transitionProperty: 'none',
|
||||
transitionDuration: '0s',
|
||||
},
|
||||
};
|
||||
|
||||
export const ProgressIndicator = memo((props: CircularProgressProps) => {
|
||||
const isGenerationInProgress = useIsGenerationInProgress();
|
||||
const lastProgressEvent = useStore($lastProgressEvent);
|
||||
if (!isGenerationInProgress) {
|
||||
return null;
|
||||
}
|
||||
if (!lastProgressEvent) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Tooltip label={formatProgressMessage(lastProgressEvent)}>
|
||||
<CircularProgress
|
||||
size="14px"
|
||||
color="invokeBlue.500"
|
||||
thickness={14}
|
||||
isIndeterminate={!lastProgressEvent || lastProgressEvent.percentage === null}
|
||||
value={lastProgressEvent?.percentage ? lastProgressEvent.percentage * 100 : undefined}
|
||||
sx={circleStyles}
|
||||
{...props}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
ProgressIndicator.displayName = 'ProgressMessage';
|
||||
@@ -7,10 +7,16 @@ import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
export const DROP_SHADOW = 'drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.3))';
|
||||
|
||||
export type ComparisonWrapperProps = {
|
||||
firstImage: ImageDTO | null;
|
||||
secondImage: ImageDTO | null;
|
||||
rect: DOMRect | null;
|
||||
};
|
||||
|
||||
export type ComparisonProps = {
|
||||
firstImage: ImageDTO;
|
||||
secondImage: ImageDTO;
|
||||
containerDims: Dimensions;
|
||||
rect: DOMRect | null;
|
||||
};
|
||||
|
||||
export const fitDimsToContainer = (containerDims: Dimensions, imageDims: Dimensions): Dimensions => {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { logger } from 'app/logging/logger';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { selectAutoSwitch } from 'features/gallery/store/gallerySelectors';
|
||||
import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { type Atom, atom, computed } from 'nanostores';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import type { S } from 'services/api/types';
|
||||
import { $socket } from 'services/events/stores';
|
||||
import { assert } from 'tsafe';
|
||||
import type { JsonObject } from 'type-fest';
|
||||
|
||||
type ImageViewerContextValue = {
|
||||
$progressEvent: Atom<S['InvocationProgressEvent'] | null>;
|
||||
@@ -18,12 +21,17 @@ type ImageViewerContextValue = {
|
||||
|
||||
const ImageViewerContext = createContext<ImageViewerContextValue | null>(null);
|
||||
|
||||
const log = logger('events');
|
||||
|
||||
export const ImageViewerContextProvider = memo((props: PropsWithChildren) => {
|
||||
const socket = useStore($socket);
|
||||
const autoSwitch = useAppSelector(selectAutoSwitch);
|
||||
const $progressEvent = useState(() => atom<S['InvocationProgressEvent'] | null>(null))[0];
|
||||
const $progressImage = useState(() => atom<ProgressImageType | null>(null))[0];
|
||||
const $hasProgressImage = useState(() => computed($progressImage, (progressImage) => progressImage !== null))[0];
|
||||
// We can have race conditions where we receive a progress event for a queue item that has already finished. Easiest
|
||||
// way to handle this is to keep track of finished queue items in a cache and ignore progress events for those.
|
||||
const [finishedQueueItemIds] = useState(() => new LRUCache<number, boolean>({ max: 200 }));
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) {
|
||||
@@ -31,6 +39,13 @@ export const ImageViewerContextProvider = memo((props: PropsWithChildren) => {
|
||||
}
|
||||
|
||||
const onInvocationProgress = (data: S['InvocationProgressEvent']) => {
|
||||
if (finishedQueueItemIds.has(data.item_id)) {
|
||||
log.trace(
|
||||
{ data } as JsonObject,
|
||||
`Received InvocationProgressEvent event for already-finished queue item ${data.item_id}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
$progressEvent.set(data);
|
||||
if (data.image) {
|
||||
$progressImage.set(data.image);
|
||||
@@ -42,7 +57,7 @@ export const ImageViewerContextProvider = memo((props: PropsWithChildren) => {
|
||||
return () => {
|
||||
socket.off('invocation_progress', onInvocationProgress);
|
||||
};
|
||||
}, [$progressEvent, $progressImage, socket]);
|
||||
}, [$progressEvent, $progressImage, finishedQueueItemIds, socket]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) {
|
||||
@@ -50,12 +65,28 @@ export const ImageViewerContextProvider = memo((props: PropsWithChildren) => {
|
||||
}
|
||||
|
||||
const onQueueItemStatusChanged = (data: S['QueueItemStatusChangedEvent']) => {
|
||||
// When auto-switch is enabled, we will get a load event as we switch to the new image. This in turn clears the progress image,
|
||||
// creating the illusion of the progress image turning into the new image.
|
||||
// But when auto-switch is disabled, we won't get that load event, so we need to clear the progress image manually.
|
||||
if (data.origin === 'canvas' || !autoSwitch) {
|
||||
$progressEvent.set(null);
|
||||
$progressImage.set(null);
|
||||
if (finishedQueueItemIds.has(data.item_id)) {
|
||||
log.trace(
|
||||
{ data } as JsonObject,
|
||||
`Received QueueItemStatusChangedEvent event for already-finished queue item ${data.item_id}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (data.status === 'completed' || data.status === 'canceled' || data.status === 'failed') {
|
||||
finishedQueueItemIds.set(data.item_id, true);
|
||||
// Completed queue items have the progress event cleared by the onLoadImage callback. This allows the viewer to
|
||||
// create the illusion of the progress image "resolving" into the final image. If we cleared the progress image
|
||||
// now, there would be a flicker where the progress image disappears before the final image appears, and the
|
||||
// last-selected gallery image should be shown for a brief moment.
|
||||
//
|
||||
// When gallery auto-switch is disabled, we do not need to create this illusion, because we are not going to
|
||||
// switch to the final image automatically. In this case, we clear the progress image immediately.
|
||||
//
|
||||
// We also clear the progress image if the queue item is canceled or failed, as there is no final image to show.
|
||||
if (data.status === 'canceled' || data.status === 'failed' || !autoSwitch) {
|
||||
$progressEvent.set(null);
|
||||
$progressImage.set(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -64,7 +95,7 @@ export const ImageViewerContextProvider = memo((props: PropsWithChildren) => {
|
||||
return () => {
|
||||
socket.off('queue_item_status_changed', onQueueItemStatusChanged);
|
||||
};
|
||||
}, [$progressEvent, $progressImage, autoSwitch, socket]);
|
||||
}, [$progressEvent, $progressImage, autoSwitch, finishedQueueItemIds, socket]);
|
||||
|
||||
const onLoadImage = useCallback(() => {
|
||||
$progressEvent.set(null);
|
||||
|
||||
@@ -59,7 +59,7 @@ const ImageAtPosition = memo(({ imageName }: { index: number; imageName: string
|
||||
imagesApi.endpoints.getImageDTO.useQuerySubscription(imageName, { skip: isUninitialized });
|
||||
|
||||
if (!imageDTO) {
|
||||
return <GalleryImagePlaceholder />;
|
||||
return <GalleryImagePlaceholder data-image-name={imageName} />;
|
||||
}
|
||||
|
||||
return <GalleryImage imageDTO={imageDTO} />;
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { objectEquals } from '@observ33r/object-equals';
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import type { PersistConfig, RootState } from 'app/store/store';
|
||||
import type { RootState } from 'app/store/store';
|
||||
import type { SliceConfig } from 'app/store/types';
|
||||
import { isPlainObject } from 'es-toolkit';
|
||||
import { uniq } from 'es-toolkit/compat';
|
||||
import type { BoardRecordOrderBy } from 'services/api/types';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
import type { BoardId, ComparisonMode, GalleryState, GalleryView, OrderDir } from './types';
|
||||
import {
|
||||
type BoardId,
|
||||
type ComparisonMode,
|
||||
type GalleryState,
|
||||
type GalleryView,
|
||||
type OrderDir,
|
||||
zGalleryState,
|
||||
} from './types';
|
||||
|
||||
const initialGalleryState: GalleryState = {
|
||||
const getInitialState = (): GalleryState => ({
|
||||
selection: [],
|
||||
shouldAutoSwitch: true,
|
||||
autoAssignBoardOnClick: true,
|
||||
@@ -26,11 +36,11 @@ const initialGalleryState: GalleryState = {
|
||||
shouldShowArchivedBoards: false,
|
||||
boardsListOrderBy: 'created_at',
|
||||
boardsListOrderDir: 'DESC',
|
||||
};
|
||||
});
|
||||
|
||||
export const gallerySlice = createSlice({
|
||||
const slice = createSlice({
|
||||
name: 'gallery',
|
||||
initialState: initialGalleryState,
|
||||
initialState: getInitialState(),
|
||||
reducers: {
|
||||
imageSelected: (state, action: PayloadAction<string | null>) => {
|
||||
// Let's be efficient here and not update the selection unless it has actually changed. This helps to prevent
|
||||
@@ -187,21 +197,22 @@ export const {
|
||||
searchTermChanged,
|
||||
boardsListOrderByChanged,
|
||||
boardsListOrderDirChanged,
|
||||
} = gallerySlice.actions;
|
||||
} = slice.actions;
|
||||
|
||||
export const selectGallerySlice = (state: RootState) => state.gallery;
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
const migrateGalleryState = (state: any): any => {
|
||||
if (!('_version' in state)) {
|
||||
state._version = 1;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
export const galleryPersistConfig: PersistConfig<GalleryState> = {
|
||||
name: gallerySlice.name,
|
||||
initialState: initialGalleryState,
|
||||
migrate: migrateGalleryState,
|
||||
persistDenylist: ['selection', 'selectedBoardId', 'galleryView', 'imageToCompare'],
|
||||
export const gallerySliceConfig: SliceConfig<typeof slice> = {
|
||||
slice,
|
||||
schema: zGalleryState,
|
||||
getInitialState,
|
||||
persistConfig: {
|
||||
migrate: (state) => {
|
||||
assert(isPlainObject(state));
|
||||
if (!('_version' in state)) {
|
||||
state._version = 1;
|
||||
}
|
||||
return zGalleryState.parse(state);
|
||||
},
|
||||
persistDenylist: ['selection', 'selectedBoardId', 'galleryView', 'imageToCompare'],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { S } from 'services/api/types';
|
||||
import type { Equals } from 'tsafe';
|
||||
import { assert } from 'tsafe';
|
||||
import { describe, test } from 'vitest';
|
||||
|
||||
import type { BoardRecordOrderBy } from './types';
|
||||
|
||||
describe('Gallery Types', () => {
|
||||
// Ensure zod types match OpenAPI types
|
||||
test('BoardRecordOrderBy', () => {
|
||||
assert<Equals<BoardRecordOrderBy, S['BoardRecordOrderBy']>>();
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,42 @@
|
||||
import type { BoardRecordOrderBy, ImageCategory } from 'services/api/types';
|
||||
import type { ImageCategory } from 'services/api/types';
|
||||
import z from 'zod';
|
||||
|
||||
const zGalleryView = z.enum(['images', 'assets']);
|
||||
export type GalleryView = z.infer<typeof zGalleryView>;
|
||||
const zBoardId = z.string();
|
||||
// TS hack to get autocomplete for "none" but accept any string
|
||||
export type BoardId = 'none' | (string & {});
|
||||
const zComparisonMode = z.enum(['slider', 'side-by-side', 'hover']);
|
||||
export type ComparisonMode = z.infer<typeof zComparisonMode>;
|
||||
const zComparisonFit = z.enum(['contain', 'fill']);
|
||||
export type ComparisonFit = z.infer<typeof zComparisonFit>;
|
||||
const zOrderDir = z.enum(['ASC', 'DESC']);
|
||||
export type OrderDir = z.infer<typeof zOrderDir>;
|
||||
const zBoardRecordOrderBy = z.enum(['created_at', 'board_name']);
|
||||
export type BoardRecordOrderBy = z.infer<typeof zBoardRecordOrderBy>;
|
||||
|
||||
export const IMAGE_CATEGORIES: ImageCategory[] = ['general'];
|
||||
export const ASSETS_CATEGORIES: ImageCategory[] = ['control', 'mask', 'user', 'other'];
|
||||
|
||||
export type GalleryView = 'images' | 'assets';
|
||||
export type BoardId = 'none' | (string & Record<never, never>);
|
||||
export type ComparisonMode = 'slider' | 'side-by-side' | 'hover';
|
||||
export type ComparisonFit = 'contain' | 'fill';
|
||||
export type OrderDir = 'ASC' | 'DESC';
|
||||
export const zGalleryState = z.object({
|
||||
selection: z.array(z.string()),
|
||||
shouldAutoSwitch: z.boolean(),
|
||||
autoAssignBoardOnClick: z.boolean(),
|
||||
autoAddBoardId: zBoardId,
|
||||
galleryImageMinimumWidth: z.number(),
|
||||
selectedBoardId: zBoardId,
|
||||
galleryView: zGalleryView,
|
||||
boardSearchText: z.string(),
|
||||
starredFirst: z.boolean(),
|
||||
orderDir: zOrderDir,
|
||||
searchTerm: z.string(),
|
||||
alwaysShowImageSizeBadge: z.boolean(),
|
||||
imageToCompare: z.string().nullable(),
|
||||
comparisonMode: zComparisonMode,
|
||||
comparisonFit: zComparisonFit,
|
||||
shouldShowArchivedBoards: z.boolean(),
|
||||
boardsListOrderBy: zBoardRecordOrderBy,
|
||||
boardsListOrderDir: zOrderDir,
|
||||
});
|
||||
|
||||
export type GalleryState = {
|
||||
selection: string[];
|
||||
shouldAutoSwitch: boolean;
|
||||
autoAssignBoardOnClick: boolean;
|
||||
autoAddBoardId: BoardId;
|
||||
galleryImageMinimumWidth: number;
|
||||
selectedBoardId: BoardId;
|
||||
galleryView: GalleryView;
|
||||
boardSearchText: string;
|
||||
starredFirst: boolean;
|
||||
orderDir: OrderDir;
|
||||
searchTerm: string;
|
||||
alwaysShowImageSizeBadge: boolean;
|
||||
imageToCompare: string | null;
|
||||
comparisonMode: ComparisonMode;
|
||||
comparisonFit: ComparisonFit;
|
||||
shouldShowArchivedBoards: boolean;
|
||||
boardsListOrderBy: BoardRecordOrderBy;
|
||||
boardsListOrderDir: OrderDir;
|
||||
};
|
||||
export type GalleryState = z.infer<typeof zGalleryState>;
|
||||
|
||||
@@ -58,7 +58,7 @@ export const setRegionalGuidanceReferenceImage = (arg: {
|
||||
|
||||
export const setUpscaleInitialImage = (arg: { imageDTO: ImageDTO; dispatch: AppDispatch }) => {
|
||||
const { imageDTO, dispatch } = arg;
|
||||
dispatch(upscaleInitialImageChanged(imageDTO));
|
||||
dispatch(upscaleInitialImageChanged(imageDTOToImageWithDims(imageDTO)));
|
||||
};
|
||||
|
||||
export const setNodeImageFieldImage = (arg: {
|
||||
|
||||
@@ -89,6 +89,7 @@ import { t } from 'i18next';
|
||||
import type { ComponentType } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
import { modelsApi } from 'services/api/endpoints/models';
|
||||
import type { AnyModelConfig, ModelType } from 'services/api/types';
|
||||
import { assert } from 'tsafe';
|
||||
@@ -787,11 +788,55 @@ const LoRAs: CollectionMetadataHandler<LoRA[]> = {
|
||||
const CanvasLayers: SingleMetadataHandler<CanvasMetadata> = {
|
||||
[SingleMetadataKey]: true,
|
||||
type: 'CanvasLayers',
|
||||
parse: async (metadata) => {
|
||||
parse: async (metadata, store) => {
|
||||
const raw = getProperty(metadata, 'canvas_v2_metadata');
|
||||
// This validator fetches all referenced images. If any do not exist, validation fails. The logic for this is in
|
||||
// the zImageWithDims schema.
|
||||
const parsed = await zCanvasMetadata.parseAsync(raw);
|
||||
|
||||
for (const entity of parsed.controlLayers) {
|
||||
if (entity.controlAdapter.model) {
|
||||
await throwIfModelDoesNotExist(entity.controlAdapter.model.key, store);
|
||||
}
|
||||
for (const object of entity.objects) {
|
||||
if (object.type === 'image' && 'image_name' in object.image) {
|
||||
await throwIfImageDoesNotExist(object.image.image_name, store);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entity of parsed.inpaintMasks) {
|
||||
for (const object of entity.objects) {
|
||||
if (object.type === 'image' && 'image_name' in object.image) {
|
||||
await throwIfImageDoesNotExist(object.image.image_name, store);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entity of parsed.rasterLayers) {
|
||||
for (const object of entity.objects) {
|
||||
if (object.type === 'image' && 'image_name' in object.image) {
|
||||
await throwIfImageDoesNotExist(object.image.image_name, store);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entity of parsed.regionalGuidance) {
|
||||
for (const object of entity.objects) {
|
||||
if (object.type === 'image' && 'image_name' in object.image) {
|
||||
await throwIfImageDoesNotExist(object.image.image_name, store);
|
||||
}
|
||||
}
|
||||
for (const refImage of entity.referenceImages) {
|
||||
if (refImage.config.image) {
|
||||
await throwIfImageDoesNotExist(refImage.config.image.image_name, store);
|
||||
}
|
||||
if (refImage.config.model) {
|
||||
await throwIfModelDoesNotExist(refImage.config.model.key, store);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve(parsed);
|
||||
},
|
||||
recall: (value, store) => {
|
||||
@@ -824,27 +869,39 @@ const CanvasLayers: SingleMetadataHandler<CanvasMetadata> = {
|
||||
const RefImages: CollectionMetadataHandler<RefImageState[]> = {
|
||||
[CollectionMetadataKey]: true,
|
||||
type: 'RefImages',
|
||||
parse: async (metadata) => {
|
||||
parse: async (metadata, store) => {
|
||||
let parsed: RefImageState[] | null = null;
|
||||
try {
|
||||
// First attempt to parse from the v6 slot
|
||||
const raw = getProperty(metadata, 'ref_images');
|
||||
// This validator fetches all referenced images. If any do not exist, validation fails. The logic for this is in
|
||||
// the zImageWithDims schema.
|
||||
const parsed = await z.array(zRefImageState).parseAsync(raw);
|
||||
return Promise.resolve(parsed);
|
||||
parsed = z.array(zRefImageState).parse(raw);
|
||||
} catch {
|
||||
// Fall back to extracting from canvas metadata]
|
||||
const raw = getProperty(metadata, 'canvas_v2_metadata.referenceImages.entities');
|
||||
// This validator fetches all referenced images. If any do not exist, validation fails. The logic for this is in
|
||||
// the zImageWithDims schema.
|
||||
const oldParsed = await z.array(zCanvasReferenceImageState_OLD).parseAsync(raw);
|
||||
const parsed: RefImageState[] = oldParsed.map(({ id, ipAdapter, isEnabled }) => ({
|
||||
parsed = oldParsed.map(({ id, ipAdapter, isEnabled }) => ({
|
||||
id,
|
||||
config: ipAdapter,
|
||||
isEnabled,
|
||||
}));
|
||||
return parsed;
|
||||
}
|
||||
|
||||
if (!parsed) {
|
||||
throw new Error('No valid reference images found in metadata');
|
||||
}
|
||||
|
||||
for (const refImage of parsed) {
|
||||
if (refImage.config.image) {
|
||||
await throwIfImageDoesNotExist(refImage.config.image.image_name, store);
|
||||
}
|
||||
if (refImage.config.model) {
|
||||
await throwIfModelDoesNotExist(refImage.config.model.key, store);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
},
|
||||
recall: (value, store) => {
|
||||
const entities = value.map((data) => ({ ...data, id: getPrefixedId('reference_image') }));
|
||||
@@ -1241,3 +1298,19 @@ const isCompatibleWithMainModel = (candidate: ModelIdentifierField, store: AppSt
|
||||
}
|
||||
return candidate.base === base;
|
||||
};
|
||||
|
||||
const throwIfImageDoesNotExist = async (name: string, store: AppStore): Promise<void> => {
|
||||
try {
|
||||
await store.dispatch(imagesApi.endpoints.getImageDTO.initiate(name, { subscribe: false })).unwrap();
|
||||
} catch {
|
||||
throw new Error(`Image with name ${name} does not exist`);
|
||||
}
|
||||
};
|
||||
|
||||
const throwIfModelDoesNotExist = async (key: string, store: AppStore): Promise<void> => {
|
||||
try {
|
||||
await store.dispatch(modelsApi.endpoints.getModelConfig.initiate(key, { subscribe: false }));
|
||||
} catch {
|
||||
throw new Error(`Model with key ${key} does not exist`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { getStore } from 'app/store/nanostores/store';
|
||||
import type { ModelIdentifierField } from 'features/nodes/types/common';
|
||||
import { modelsApi } from 'services/api/endpoints/models';
|
||||
import type { AnyModelConfig, BaseModelType, ModelType } from 'services/api/types';
|
||||
import type { AnyModelConfig } from 'services/api/types';
|
||||
|
||||
/**
|
||||
* Raised when a model config is unable to be fetched.
|
||||
@@ -47,45 +46,6 @@ const fetchModelConfig = async (key: string): Promise<AnyModelConfig> => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the model config for a given model name, base model, and model type. This provides backwards compatibility
|
||||
* for MM1 model identifiers.
|
||||
* @param name The model name.
|
||||
* @param base The base model.
|
||||
* @param type The model type.
|
||||
* @returns A promise that resolves to the model config.
|
||||
* @throws {ModelConfigNotFoundError} If the model config is unable to be fetched.
|
||||
*/
|
||||
const fetchModelConfigByAttrs = async (name: string, base: BaseModelType, type: ModelType): Promise<AnyModelConfig> => {
|
||||
const { dispatch } = getStore();
|
||||
try {
|
||||
const req = dispatch(
|
||||
modelsApi.endpoints.getModelConfigByAttrs.initiate({ name, base, type }, { subscribe: false })
|
||||
);
|
||||
return await req.unwrap();
|
||||
} catch {
|
||||
throw new ModelConfigNotFoundError(`Unable to retrieve model config for name/base/type ${name}/${base}/${type}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the model config given an identifier. First attempts to fetch by key, then falls back to fetching by attrs.
|
||||
* @param identifier The model identifier.
|
||||
* @returns A promise that resolves to the model config.
|
||||
* @throws {ModelConfigNotFoundError} If the model config is unable to be fetched.
|
||||
*/
|
||||
export const fetchModelConfigByIdentifier = async (identifier: ModelIdentifierField): Promise<AnyModelConfig> => {
|
||||
try {
|
||||
return await fetchModelConfig(identifier.key);
|
||||
} catch {
|
||||
try {
|
||||
return await fetchModelConfigByAttrs(identifier.name, identifier.base, identifier.type);
|
||||
} catch {
|
||||
throw new ModelConfigNotFoundError(`Unable to retrieve model config for identifier ${identifier}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the model config for a given model key and type, and ensures that the model config is of a specific type.
|
||||
* @param key The model key.
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSelector, createSlice } from '@reduxjs/toolkit';
|
||||
import type { PersistConfig, RootState } from 'app/store/store';
|
||||
import type { ModelType } from 'services/api/types';
|
||||
import type { RootState } from 'app/store/store';
|
||||
import type { SliceConfig } from 'app/store/types';
|
||||
import { isPlainObject } from 'es-toolkit';
|
||||
import { zModelType } from 'features/nodes/types/common';
|
||||
import { assert } from 'tsafe';
|
||||
import z from 'zod';
|
||||
|
||||
export type FilterableModelType = Exclude<ModelType, 'onnx'> | 'refiner';
|
||||
const zFilterableModelType = zModelType.exclude(['onnx']).or(z.literal('refiner'));
|
||||
export type FilterableModelType = z.infer<typeof zFilterableModelType>;
|
||||
|
||||
type ModelManagerState = {
|
||||
_version: 1;
|
||||
selectedModelKey: string | null;
|
||||
selectedModelMode: 'edit' | 'view';
|
||||
searchTerm: string;
|
||||
filteredModelType: FilterableModelType | null;
|
||||
scanPath: string | undefined;
|
||||
shouldInstallInPlace: boolean;
|
||||
};
|
||||
const zModelManagerState = z.object({
|
||||
_version: z.literal(1),
|
||||
selectedModelKey: z.string().nullable(),
|
||||
selectedModelMode: z.enum(['edit', 'view']),
|
||||
searchTerm: z.string(),
|
||||
filteredModelType: zFilterableModelType.nullable(),
|
||||
scanPath: z.string().optional(),
|
||||
shouldInstallInPlace: z.boolean(),
|
||||
});
|
||||
|
||||
const initialModelManagerState: ModelManagerState = {
|
||||
type ModelManagerState = z.infer<typeof zModelManagerState>;
|
||||
|
||||
const getInitialState = (): ModelManagerState => ({
|
||||
_version: 1,
|
||||
selectedModelKey: null,
|
||||
selectedModelMode: 'view',
|
||||
@@ -23,11 +30,11 @@ const initialModelManagerState: ModelManagerState = {
|
||||
searchTerm: '',
|
||||
scanPath: undefined,
|
||||
shouldInstallInPlace: true,
|
||||
};
|
||||
});
|
||||
|
||||
export const modelManagerV2Slice = createSlice({
|
||||
const slice = createSlice({
|
||||
name: 'modelmanagerV2',
|
||||
initialState: initialModelManagerState,
|
||||
initialState: getInitialState(),
|
||||
reducers: {
|
||||
setSelectedModelKey: (state, action: PayloadAction<string | null>) => {
|
||||
state.selectedModelMode = 'view';
|
||||
@@ -58,21 +65,22 @@ export const {
|
||||
setSelectedModelMode,
|
||||
setScanPath,
|
||||
shouldInstallInPlaceChanged,
|
||||
} = modelManagerV2Slice.actions;
|
||||
} = slice.actions;
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
const migrateModelManagerState = (state: any): any => {
|
||||
if (!('_version' in state)) {
|
||||
state._version = 1;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
export const modelManagerV2PersistConfig: PersistConfig<ModelManagerState> = {
|
||||
name: modelManagerV2Slice.name,
|
||||
initialState: initialModelManagerState,
|
||||
migrate: migrateModelManagerState,
|
||||
persistDenylist: ['selectedModelKey', 'selectedModelMode', 'filteredModelType', 'searchTerm'],
|
||||
export const modelManagerSliceConfig: SliceConfig<typeof slice> = {
|
||||
slice,
|
||||
schema: zModelManagerState,
|
||||
getInitialState,
|
||||
persistConfig: {
|
||||
migrate: (state) => {
|
||||
assert(isPlainObject(state));
|
||||
if (!('_version' in state)) {
|
||||
state._version = 1;
|
||||
}
|
||||
return zModelManagerState.parse(state);
|
||||
},
|
||||
persistDenylist: ['selectedModelKey', 'selectedModelMode', 'filteredModelType', 'searchTerm'],
|
||||
},
|
||||
};
|
||||
|
||||
export const selectModelManagerV2Slice = (state: RootState) => state.modelmanagerV2;
|
||||
|
||||
@@ -14,7 +14,13 @@ import type {
|
||||
ReactFlowProps,
|
||||
ReactFlowState,
|
||||
} from '@xyflow/react';
|
||||
import { Background, ReactFlow, useStore as useReactFlowStore, useUpdateNodeInternals } from '@xyflow/react';
|
||||
import {
|
||||
Background,
|
||||
ReactFlow,
|
||||
SelectionMode,
|
||||
useStore as useReactFlowStore,
|
||||
useUpdateNodeInternals,
|
||||
} from '@xyflow/react';
|
||||
import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { useFocusRegion, useIsRegionFocused } from 'common/hooks/focus';
|
||||
import { $isSelectingOutputNode, $outputNodeId } from 'features/nodes/components/sidePanel/workflow/publish';
|
||||
@@ -256,7 +262,7 @@ export const Flow = memo(() => {
|
||||
style={flowStyles}
|
||||
onPaneClick={handlePaneClick}
|
||||
deleteKeyCode={null}
|
||||
selectionMode={selectionMode}
|
||||
selectionMode={selectionMode === 'full' ? SelectionMode.Full : SelectionMode.Partial}
|
||||
elevateEdgesOnSelect
|
||||
nodeDragThreshold={1}
|
||||
noDragClassName={NO_DRAG_CLASS}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
import { DndImage } from 'features/dnd/DndImage';
|
||||
import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons';
|
||||
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
|
||||
import NodeWrapper from 'features/nodes/components/flow/nodes/common/NodeWrapper';
|
||||
import NonInvocationNodeWrapper from 'features/nodes/components/flow/nodes/common/NonInvocationNodeWrapper';
|
||||
import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants';
|
||||
import type { AnimationProps } from 'framer-motion';
|
||||
import { motion } from 'framer-motion';
|
||||
@@ -58,13 +58,14 @@ const Wrapper = (props: PropsWithChildren<{ nodeProps: NodeProps }>) => {
|
||||
}, []);
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<NodeWrapper nodeId={props.nodeProps.id} selected={props.nodeProps.selected} width={384}>
|
||||
<NonInvocationNodeWrapper nodeId={props.nodeProps.id} selected={props.nodeProps.selected} width={384}>
|
||||
<Flex
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={DRAG_HANDLE_CLASSNAME}
|
||||
position="relative"
|
||||
flexDirection="column"
|
||||
aspectRatio="1/1"
|
||||
>
|
||||
<Flex layerStyle="nodeHeader" borderTopRadius="base" alignItems="center" justifyContent="center" h={8}>
|
||||
<Text fontSize="sm" fontWeight="semibold" color="base.200">
|
||||
@@ -80,7 +81,7 @@ const Wrapper = (props: PropsWithChildren<{ nodeProps: NodeProps }>) => {
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</NodeWrapper>
|
||||
</NonInvocationNodeWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Selector } from '@reduxjs/toolkit';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import type { RootState } from 'app/store/store';
|
||||
import { $templates } from 'features/nodes/store/nodesSlice';
|
||||
import { selectEdges, selectNodes } from 'features/nodes/store/selectors';
|
||||
import { selectEdges, selectNodeFieldElements, selectNodes } from 'features/nodes/store/selectors';
|
||||
import type { InvocationNode, InvocationTemplate } from 'features/nodes/types/invocation';
|
||||
import { getNeedsUpdate } from 'features/nodes/util/node/nodeUpdate';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
@@ -27,6 +27,7 @@ type InvocationNodeContextValue = {
|
||||
buildSelectOutputFieldTemplateSafe: (
|
||||
fieldName: string
|
||||
) => Selector<RootState, InvocationTemplate['outputs'][string] | null>;
|
||||
buildSelectIsInputFieldAddedToForm: (fieldName: string) => Selector<RootState, boolean>;
|
||||
|
||||
selectNodeOrThrow: Selector<RootState, InvocationNode>;
|
||||
selectNodeDataOrThrow: Selector<RootState, InvocationNode['data']>;
|
||||
@@ -181,6 +182,15 @@ export const InvocationNodeContextProvider = memo(({ nodeId, children }: PropsWi
|
||||
})
|
||||
);
|
||||
|
||||
const buildSelectIsInputFieldAddedToForm = (fieldName: string) =>
|
||||
getSelectorFromCache(cache, `buildSelectIsInputFieldAddedToForm-${fieldName}`, () =>
|
||||
createSelector(selectNodeFieldElements, (nodeFieldElements) => {
|
||||
return nodeFieldElements.some(
|
||||
(el) => el.data.fieldIdentifier.nodeId === nodeId && el.data.fieldIdentifier.fieldName === fieldName
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
const selectNodeNeedsUpdate = getSelectorFromCache(cache, 'selectNodeNeedsUpdate', () =>
|
||||
createSelector([selectNodeDataSafe, selectNodeTemplateSafe], (data, template) => {
|
||||
if (!data || !template) {
|
||||
@@ -202,6 +212,7 @@ export const InvocationNodeContextProvider = memo(({ nodeId, children }: PropsWi
|
||||
buildSelectInputFieldSafe,
|
||||
buildSelectInputFieldTemplateSafe,
|
||||
buildSelectOutputFieldTemplateSafe,
|
||||
buildSelectIsInputFieldAddedToForm,
|
||||
|
||||
selectNodeOrThrow,
|
||||
selectNodeDataOrThrow,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Input, Text, Tooltip } from '@invoke-ai/ui-library';
|
||||
import { Icon, Input, Text, Tooltip } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useEditable } from 'common/hooks/useEditable';
|
||||
import { InputFieldTooltipContent } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldTooltipContent';
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useIsConnectionInProgress,
|
||||
useIsConnectionStartField,
|
||||
} from 'features/nodes/hooks/useFieldConnectionState';
|
||||
import { useInputFieldIsAddedToForm } from 'features/nodes/hooks/useInputFieldIsAddedToForm';
|
||||
import { useInputFieldIsConnected } from 'features/nodes/hooks/useInputFieldIsConnected';
|
||||
import { useInputFieldTemplateTitleOrThrow } from 'features/nodes/hooks/useInputFieldTemplateTitleOrThrow';
|
||||
import { useInputFieldUserTitleSafe } from 'features/nodes/hooks/useInputFieldUserTitleSafe';
|
||||
@@ -16,9 +17,13 @@ import { HANDLE_TOOLTIP_OPEN_DELAY, NO_FIT_ON_DOUBLE_CLICK_CLASS } from 'feature
|
||||
import type { MouseEvent } from 'react';
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiLinkBold } from 'react-icons/pi';
|
||||
|
||||
const labelSx: SystemStyleObject = {
|
||||
p: 0,
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
alignItems: 'center',
|
||||
fontWeight: 'semibold',
|
||||
textAlign: 'left',
|
||||
color: 'base.300',
|
||||
@@ -28,6 +33,9 @@ const labelSx: SystemStyleObject = {
|
||||
'&[data-is-invalid="true"]': {
|
||||
color: 'error.300',
|
||||
},
|
||||
'&[data-is-added-to-form="true"]': {
|
||||
color: 'blue.300',
|
||||
},
|
||||
'&[data-is-disabled="true"]': {
|
||||
opacity: 0.5,
|
||||
},
|
||||
@@ -47,6 +55,7 @@ export const InputFieldTitle = memo((props: Props) => {
|
||||
const fieldTemplateTitle = useInputFieldTemplateTitleOrThrow(fieldName);
|
||||
const { t } = useTranslation();
|
||||
const isConnected = useInputFieldIsConnected(fieldName);
|
||||
const isAddedToForm = useInputFieldIsAddedToForm(fieldName);
|
||||
const isConnectionStartField = useIsConnectionStartField(nodeId, fieldName, 'target');
|
||||
const isConnectionInProgress = useIsConnectionInProgress();
|
||||
const connectionError = useConnectionErrorTKey(nodeId, fieldName, 'target');
|
||||
@@ -93,9 +102,11 @@ export const InputFieldTitle = memo((props: Props) => {
|
||||
noOfLines={1}
|
||||
data-is-invalid={isInvalid}
|
||||
data-is-disabled={isDisabled}
|
||||
data-is-added-to-form={isAddedToForm}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
{editable.value}
|
||||
{isAddedToForm && <Icon as={PiLinkBold} color="blue.200" ml={1} />}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Flex, ListItem, Text, UnorderedList } from '@invoke-ai/ui-library';
|
||||
import { startCase } from 'es-toolkit/compat';
|
||||
import { useInputFieldErrors } from 'features/nodes/hooks/useInputFieldErrors';
|
||||
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
|
||||
import { useInputFieldIsAddedToForm } from 'features/nodes/hooks/useInputFieldIsAddedToForm';
|
||||
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
|
||||
import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
|
||||
import { memo, useMemo } from 'react';
|
||||
@@ -19,6 +20,7 @@ export const InputFieldTooltipContent = memo(({ fieldName }: Props) => {
|
||||
const fieldTemplate = useInputFieldTemplateOrThrow(fieldName);
|
||||
const fieldTypeName = useFieldTypeName(fieldTemplate.type);
|
||||
const fieldErrors = useInputFieldErrors(fieldName);
|
||||
const isAddedToForm = useInputFieldIsAddedToForm(fieldName);
|
||||
|
||||
const fieldTitle = useMemo(() => {
|
||||
if (fieldInstance.label && fieldTemplate.title) {
|
||||
@@ -34,7 +36,10 @@ export const InputFieldTooltipContent = memo(({ fieldName }: Props) => {
|
||||
|
||||
return (
|
||||
<Flex flexDir="column">
|
||||
<Text fontWeight="semibold">{fieldTitle}</Text>
|
||||
<Text fontWeight="semibold">
|
||||
{fieldTitle}
|
||||
{isAddedToForm && ' (added to form)'}
|
||||
</Text>
|
||||
<Text opacity={0.7} fontStyle="oblique 5deg">
|
||||
{fieldTemplate.description}
|
||||
</Text>
|
||||
|
||||
@@ -3,8 +3,8 @@ import { createSelector } from '@reduxjs/toolkit';
|
||||
import type { Node, NodeProps } from '@xyflow/react';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import NodeCollapseButton from 'features/nodes/components/flow/nodes/common/NodeCollapseButton';
|
||||
import NodeTitle from 'features/nodes/components/flow/nodes/common/NodeTitle';
|
||||
import NodeWrapper from 'features/nodes/components/flow/nodes/common/NodeWrapper';
|
||||
import NonInvocationNodeTitle from 'features/nodes/components/flow/nodes/common/NonInvocationNodeTitle';
|
||||
import NonInvocationNodeWrapper from 'features/nodes/components/flow/nodes/common/NonInvocationNodeWrapper';
|
||||
import { notesNodeValueChanged } from 'features/nodes/store/nodesSlice';
|
||||
import { selectNodes } from 'features/nodes/store/selectors';
|
||||
import { NO_DRAG_CLASS, NO_PAN_CLASS } from 'features/nodes/types/constants';
|
||||
@@ -34,7 +34,7 @@ const NotesNode = (props: NodeProps<Node<NotesNodeData>>) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeWrapper nodeId={nodeId} selected={selected}>
|
||||
<NonInvocationNodeWrapper nodeId={nodeId} selected={selected}>
|
||||
<Flex
|
||||
layerStyle="nodeHeader"
|
||||
borderTopRadius="base"
|
||||
@@ -44,7 +44,7 @@ const NotesNode = (props: NodeProps<Node<NotesNodeData>>) => {
|
||||
h={8}
|
||||
>
|
||||
<NodeCollapseButton nodeId={nodeId} isOpen={isOpen} />
|
||||
<NodeTitle nodeId={nodeId} title="Notes" />
|
||||
<NonInvocationNodeTitle nodeId={nodeId} title="Notes" />
|
||||
<Box minW={8} />
|
||||
</Flex>
|
||||
{isOpen && (
|
||||
@@ -73,7 +73,7 @@ const NotesNode = (props: NodeProps<Node<NotesNodeData>>) => {
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</NodeWrapper>
|
||||
</NonInvocationNodeWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user