Compare commits

..

1 Commits

Author SHA1 Message Date
psychedelicious
7c8891c3bf chore: bump version 2023-07-09 23:01:39 +10:00
156 changed files with 2478 additions and 5409 deletions

View File

@@ -1,9 +1,25 @@
# use this file as a whitelist
*
!invokeai
!ldm
!pyproject.toml
!docker/docker-entrypoint.sh
!LICENSE
**/node_modules
**/__pycache__
**/*.egg-info
# ignore frontend/web but whitelist dist
invokeai/frontend/web/
!invokeai/frontend/web/dist/
# ignore invokeai/assets but whitelist invokeai/assets/web
invokeai/assets/
!invokeai/assets/web/
# Guard against pulling in any models that might exist in the directory tree
**/*.pt*
**/*.ckpt
# Byte-compiled / optimized / DLL files
**/__pycache__/
**/*.py[cod]
# Distribution / packaging
**/*.egg-info/
**/*.egg

View File

@@ -3,15 +3,17 @@ on:
push:
branches:
- 'main'
- 'update/ci/docker/*'
- 'update/docker/*'
- 'dev/ci/docker/*'
- 'dev/docker/*'
paths:
- 'pyproject.toml'
- '.dockerignore'
- 'invokeai/**'
- 'docker/Dockerfile'
- 'docker/docker-entrypoint.sh'
- 'workflows/build-container.yml'
tags:
- 'v*'
- 'v*.*.*'
workflow_dispatch:
permissions:
@@ -24,27 +26,23 @@ jobs:
strategy:
fail-fast: false
matrix:
gpu-driver:
- cuda
- cpu
- rocm
flavor:
- rocm
- cuda
- cpu
include:
- flavor: rocm
pip-extra-index-url: 'https://download.pytorch.org/whl/rocm5.2'
- flavor: cuda
pip-extra-index-url: ''
- flavor: cpu
pip-extra-index-url: 'https://download.pytorch.org/whl/cpu'
runs-on: ubuntu-latest
name: ${{ matrix.gpu-driver }}
name: ${{ matrix.flavor }}
env:
# torch/arm64 does not support GPU currently, so arm64 builds
# would not be GPU-accelerated.
# re-enable arm64 if there is sufficient demand.
# PLATFORMS: 'linux/amd64,linux/arm64'
PLATFORMS: 'linux/amd64'
PLATFORMS: 'linux/amd64,linux/arm64'
DOCKERFILE: 'docker/Dockerfile'
steps:
- name: Free up more disk space on the runner
# https://github.com/actions/runner-images/issues/2840#issuecomment-1284059930
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
sudo swapoff /mnt/swapfile
sudo rm -rf /mnt/swapfile
- name: Checkout
uses: actions/checkout@v3
@@ -55,7 +53,7 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }}
images: |
ghcr.io/${{ github.repository }}
${{ env.DOCKERHUB_REPOSITORY }}
${{ vars.DOCKERHUB_REPOSITORY }}
tags: |
type=ref,event=branch
type=ref,event=tag
@@ -64,8 +62,8 @@ jobs:
type=pep440,pattern={{major}}
type=sha,enable=true,prefix=sha-,format=short
flavor: |
latest=${{ matrix.gpu-driver == 'cuda' && github.ref == 'refs/heads/main' }}
suffix=-${{ matrix.gpu-driver }},onlatest=false
latest=${{ matrix.flavor == 'cuda' && github.ref == 'refs/heads/main' }}
suffix=-${{ matrix.flavor }},onlatest=false
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
@@ -83,33 +81,34 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
# - name: Login to Docker Hub
# if: github.event_name != 'pull_request' && vars.DOCKERHUB_REPOSITORY != ''
# uses: docker/login-action@v2
# with:
# username: ${{ secrets.DOCKERHUB_USERNAME }}
# password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Docker Hub
if: github.event_name != 'pull_request' && vars.DOCKERHUB_REPOSITORY != ''
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build container
id: docker_build
uses: docker/build-push-action@v4
with:
context: .
file: docker/Dockerfile
file: ${{ env.DOCKERFILE }}
platforms: ${{ env.PLATFORMS }}
push: ${{ github.ref == 'refs/heads/main' || github.ref_type == 'tag' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: PIP_EXTRA_INDEX_URL=${{ matrix.pip-extra-index-url }}
cache-from: |
type=gha,scope=${{ github.ref_name }}-${{ matrix.gpu-driver }}
type=gha,scope=main-${{ matrix.gpu-driver }}
cache-to: type=gha,mode=max,scope=${{ github.ref_name }}-${{ matrix.gpu-driver }}
type=gha,scope=${{ github.ref_name }}-${{ matrix.flavor }}
type=gha,scope=main-${{ matrix.flavor }}
cache-to: type=gha,mode=max,scope=${{ github.ref_name }}-${{ matrix.flavor }}
# - name: Docker Hub Description
# if: github.ref == 'refs/heads/main' || github.ref == 'refs/tags/*' && vars.DOCKERHUB_REPOSITORY != ''
# uses: peter-evans/dockerhub-description@v3
# with:
# username: ${{ secrets.DOCKERHUB_USERNAME }}
# password: ${{ secrets.DOCKERHUB_TOKEN }}
# repository: ${{ vars.DOCKERHUB_REPOSITORY }}
# short-description: ${{ github.event.repository.description }}
- name: Docker Hub Description
if: github.ref == 'refs/heads/main' || github.ref == 'refs/tags/*' && vars.DOCKERHUB_REPOSITORY != ''
uses: peter-evans/dockerhub-description@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: ${{ vars.DOCKERHUB_REPOSITORY }}
short-description: ${{ github.event.repository.description }}

189
LICENSE
View File

@@ -1,176 +1,21 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
MIT License
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
Copyright (c) 2022 InvokeAI Team
1. Definitions.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -3,8 +3,8 @@
![project hero](https://github.com/invoke-ai/InvokeAI/assets/31807370/1a917d94-e099-4fa1-a70f-7dd8d0691018)
# Invoke AI - Generative AI for Professional Creatives
## Professional Creative Tools for Stable Diffusion, Custom-Trained Models, and more.
To learn more about Invoke AI, get started instantly, or implement our Business solutions, visit [invoke.ai](https://invoke.ai)
## Image Generation for Stable Diffusion, Custom-Trained Models, and more.
Learn more about us and get started instantly at [invoke.ai](https://invoke.ai)
[![discord badge]][discord link]
@@ -329,24 +329,24 @@ InvokeAI offers a locally hosted Web Server & React Frontend, with an industry l
The Unified Canvas is a fully integrated canvas implementation with support for all core generation capabilities, in/outpainting, brush tools, and more. This creative tool unlocks the capability for artists to create with AI as a creative collaborator, and can be used to augment AI-generated imagery, sketches, photography, renders, and more.
### *Node Architecture & Editor (Beta)*
### *Advanced Prompt Syntax*
Invoke AI's backend is built on a graph-based execution architecture. This allows for customizable generation pipelines to be developed by professional users looking to create specific workflows to support their production use-cases, and will be extended in the future with additional capabilities.
Invoke AI's advanced prompt syntax allows for token weighting, cross-attention control, and prompt blending, allowing for fine-tuned tweaking of your invocations and exploration of the latent space.
### *Board & Gallery Management*
### *Command Line Interface*
Invoke AI provides an organized gallery system for easily storing, accessing, and remixing your content in the Invoke workspace. Images can be dragged/dropped onto any Image-base UI element in the application, and rich metadata within the Image allows for easy recall of key prompts or settings used in your workflow.
For users utilizing a terminal-based environment, or who want to take advantage of CLI features, InvokeAI offers an extensive and actively supported command-line interface that provides the full suite of generation functionality available in the tool.
### Other features
- *Support for both ckpt and diffusers models*
- *SD 2.0, 2.1 support*
- *Upscaling Tools*
- *Upscaling & Face Restoration Tools*
- *Embedding Manager & Support*
- *Model Manager & Support*
- *Node-Based Architecture*
- *Node-Based Plug-&-Play UI (Beta)*
- *SDXL Support* (Coming soon)
- *Boards & Gallery Management
### Latest Changes
@@ -359,7 +359,7 @@ Notes](https://github.com/invoke-ai/InvokeAI/releases) and the
Please check out our **[Q&A](https://invoke-ai.github.io/InvokeAI/help/TROUBLESHOOT/#faq)** to get solutions for common installation
problems and other issues.
## Contributing
## 🤝 Contributing
Anyone who wishes to contribute to this project, whether documentation, features, bug fixes, code
cleanup, testing, or code reviews, is very much encouraged to do so.
@@ -378,7 +378,7 @@ to become part of our community.
Welcome to InvokeAI!
### Contributors
### 👥 Contributors
This fork is a combined effort of various people from across the world.
[Check out the list of all these amazing people](https://invoke-ai.github.io/InvokeAI/other/CONTRIBUTORS/). We thank them for

View File

@@ -1,13 +0,0 @@
## Make a copy of this file named `.env` and fill in the values below.
## Any environment variables supported by InvokeAI can be specified here.
# INVOKEAI_ROOT is the path to a path on the local filesystem where InvokeAI will store data.
# Outputs will also be stored here by default.
# This **must** be an absolute path.
INVOKEAI_ROOT=
HUGGINGFACE_TOKEN=
## optional variables specific to the docker setup
# GPU_DRIVER=cuda
# CONTAINER_UID=1000

View File

@@ -1,129 +1,107 @@
# syntax=docker/dockerfile:1.4
# syntax=docker/dockerfile:1
## Builder stage
ARG PYTHON_VERSION=3.9
##################
## base image ##
##################
FROM --platform=${TARGETPLATFORM} python:${PYTHON_VERSION}-slim AS python-base
FROM library/ubuntu:22.04 AS builder
LABEL org.opencontainers.image.authors="mauwii@outlook.de"
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,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt update && apt-get install -y \
git \
python3.10-venv \
python3-pip \
build-essential
# Prepare apt for buildkit cache
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
ENV INVOKEAI_SRC=/opt/invokeai
ENV VIRTUAL_ENV=/opt/venv/invokeai
# Install dependencies
RUN \
--mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get install -y \
--no-install-recommends \
libgl1-mesa-glx=20.3.* \
libglib2.0-0=2.66.* \
libopencv-dev=4.5.*
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
ARG TORCH_VERSION=2.0.1
ARG TORCHVISION_VERSION=0.15.2
ARG GPU_DRIVER=cuda
ARG TARGETPLATFORM="linux/amd64"
# unused but available
ARG BUILDPLATFORM
# Set working directory and env
ARG APPDIR=/usr/src
ARG APPNAME=InvokeAI
WORKDIR ${APPDIR}
ENV PATH ${APPDIR}/${APPNAME}/bin:$PATH
# Keeps Python from generating .pyc files in the container
ENV PYTHONDONTWRITEBYTECODE 1
# Turns off buffering for easier container logging
ENV PYTHONUNBUFFERED 1
# Don't fall back to legacy build system
ENV PIP_USE_PEP517=1
WORKDIR ${INVOKEAI_SRC}
#######################
## build pyproject ##
#######################
FROM python-base AS pyproject-builder
# Install pytorch before all other pip packages
# NOTE: there are no pytorch builds for arm64 + cuda, only cpu
# x86_64/CUDA is default
RUN --mount=type=cache,target=/root/.cache/pip \
python3 -m venv ${VIRTUAL_ENV} &&\
if [ "$TARGETPLATFORM" = "linux/arm64" ] || [ "$GPU_DRIVER" = "cpu" ]; then \
extra_index_url_arg="--extra-index-url https://download.pytorch.org/whl/cpu"; \
elif [ "$GPU_DRIVER" = "rocm" ]; then \
extra_index_url_arg="--extra-index-url https://download.pytorch.org/whl/rocm5.4.2"; \
else \
extra_index_url_arg="--extra-index-url https://download.pytorch.org/whl/cu118"; \
fi &&\
pip install $extra_index_url_arg \
torch==$TORCH_VERSION \
torchvision==$TORCHVISION_VERSION
# Install build dependencies
RUN \
--mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get install -y \
--no-install-recommends \
build-essential=12.9 \
gcc=4:10.2.* \
python3-dev=3.9.*
# Install the local package.
# Editable mode helps use the same image for development:
# the local working copy can be bind-mounted into the image
# at path defined by ${INVOKEAI_SRC}
COPY invokeai ./invokeai
COPY pyproject.toml ./
RUN --mount=type=cache,target=/root/.cache/pip \
# xformers + triton fails to install on arm64
if [ "$GPU_DRIVER" = "cuda" ] && [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
pip install -e ".[xformers]"; \
else \
pip install -e "."; \
fi
# Prepare pip for buildkit cache
ARG PIP_CACHE_DIR=/var/cache/buildkit/pip
ENV PIP_CACHE_DIR ${PIP_CACHE_DIR}
RUN mkdir -p ${PIP_CACHE_DIR}
# #### Build the Web UI ------------------------------------
# Create virtual environment
RUN --mount=type=cache,target=${PIP_CACHE_DIR} \
python3 -m venv "${APPNAME}" \
--upgrade-deps
FROM node:18 AS web-builder
WORKDIR /build
COPY invokeai/frontend/web/ ./
RUN --mount=type=cache,target=/usr/lib/node_modules \
npm install --include dev
RUN --mount=type=cache,target=/usr/lib/node_modules \
yarn vite build
# Install requirements
COPY --link pyproject.toml .
COPY --link invokeai/version/invokeai_version.py invokeai/version/__init__.py invokeai/version/
ARG PIP_EXTRA_INDEX_URL
ENV PIP_EXTRA_INDEX_URL ${PIP_EXTRA_INDEX_URL}
RUN --mount=type=cache,target=${PIP_CACHE_DIR} \
"${APPNAME}"/bin/pip install .
# Install pyproject.toml
COPY --link . .
RUN --mount=type=cache,target=${PIP_CACHE_DIR} \
"${APPNAME}/bin/pip" install .
#### Runtime stage ---------------------------------------
FROM library/ubuntu:22.04 AS runtime
ARG DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
RUN apt update && apt install -y --no-install-recommends \
git \
curl \
vim \
tmux \
ncdu \
iotop \
bzip2 \
gosu \
libglib2.0-0 \
libgl1-mesa-glx \
python3-venv \
python3-pip \
build-essential \
libopencv-dev \
libstdc++-10-dev &&\
apt-get clean && apt-get autoclean
# globally add magic-wormhole
# for ease of transferring data to and from the container
# when running in sandboxed cloud environments; e.g. Runpod etc.
RUN pip install magic-wormhole
ENV INVOKEAI_SRC=/opt/invokeai
ENV VIRTUAL_ENV=/opt/venv/invokeai
ENV INVOKEAI_ROOT=/invokeai
ENV PATH="$VIRTUAL_ENV/bin:$INVOKEAI_SRC:$PATH"
# --link requires buldkit w/ dockerfile syntax 1.4
COPY --link --from=builder ${INVOKEAI_SRC} ${INVOKEAI_SRC}
COPY --link --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
COPY --link --from=web-builder /build/dist ${INVOKEAI_SRC}/invokeai/frontend/web/dist
# 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"
WORKDIR ${INVOKEAI_SRC}
# build patchmatch
RUN cd /usr/lib/$(uname -p)-linux-gnu/pkgconfig/ && ln -sf opencv4.pc opencv.pc
# Build patchmatch
RUN python3 -c "from patchmatch import patch_match"
# Create unprivileged user and make the local dir
RUN useradd --create-home --shell /bin/bash -u 1000 --comment "container local user" invoke
RUN mkdir -p ${INVOKEAI_ROOT} && chown -R invoke:invoke ${INVOKEAI_ROOT}
#####################
## runtime image ##
#####################
FROM python-base AS runtime
COPY docker/docker-entrypoint.sh ./
ENTRYPOINT ["/opt/invokeai/docker-entrypoint.sh"]
CMD ["invokeai-web", "--host", "0.0.0.0"]
# Create a new user
ARG UNAME=appuser
RUN useradd \
--no-log-init \
-m \
-U \
"${UNAME}"
# Create volume directory
ARG VOLUME_DIR=/data
RUN mkdir -p "${VOLUME_DIR}" \
&& chown -hR "${UNAME}:${UNAME}" "${VOLUME_DIR}"
# Setup runtime environment
USER ${UNAME}:${UNAME}
COPY --chown=${UNAME}:${UNAME} --from=pyproject-builder ${APPDIR}/${APPNAME} ${APPNAME}
ENV INVOKEAI_ROOT ${VOLUME_DIR}
ENV TRANSFORMERS_CACHE ${VOLUME_DIR}/.cache
ENV INVOKE_MODEL_RECONFIGURE "--yes --default_only"
EXPOSE 9090
ENTRYPOINT [ "invokeai" ]
CMD [ "--web", "--host", "0.0.0.0", "--port", "9090" ]
VOLUME [ "${VOLUME_DIR}" ]

View File

@@ -1,77 +0,0 @@
# InvokeAI Containerized
All commands are to be run from the `docker` directory: `cd docker`
#### Linux
1. Ensure builkit is enabled in the Docker daemon settings (`/etc/docker/daemon.json`)
2. Install the `docker compose` plugin using your package manager, or follow a [tutorial](https://www.digitalocean.com/community/tutorials/how-to-install-and-use-docker-compose-on-ubuntu-22-04).
- The deprecated `docker-compose` (hyphenated) CLI continues to work for now.
3. Ensure docker daemon is able to access the GPU.
- You may need to install [nvidia-container-toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)
#### macOS
1. Ensure Docker has at least 16GB RAM
2. Enable VirtioFS for file sharing
3. Enable `docker compose` V2 support
This is done via Docker Desktop preferences
## Quickstart
1. Make a copy of `env.sample` and name it `.env` (`cp env.sample .env` (Mac/Linux) or `copy example.env .env` (Windows)). Make changes as necessary. Set `INVOKEAI_ROOT` to an absolute path to:
a. the desired location of the InvokeAI runtime directory, or
b. an existing, v3.0.0 compatible runtime directory.
1. `docker compose up`
The image will be built automatically if needed.
The runtime directory (holding models and outputs) will be created in the location specified by `INVOKEAI_ROOT`. The default location is `~/invokeai`. The runtime directory will be populated with the base configs and models necessary to start generating.
### Use a GPU
- Linux is *recommended* for GPU support in Docker.
- WSL2 is *required* for Windows.
- only `x86_64` architecture is supported.
The Docker daemon on the system must be already set up to use the GPU. In case of Linux, this involves installing `nvidia-docker-runtime` and configuring the `nvidia` runtime as default. Steps will be different for AMD. Please see Docker documentation for the most up-to-date instructions for using your GPU with Docker.
## Customize
Check the `.env.sample` file. It contains some environment variables for running in Docker. Copy it, name it `.env`, and fill it in with your own values. Next time you run `docker compose up`, your custom values will be used.
You can also set these values in `docker compose.yml` directly, but `.env` will help avoid conflicts when code is updated.
Example (most values are optional):
```
INVOKEAI_ROOT=/Volumes/WorkDrive/invokeai
HUGGINGFACE_TOKEN=the_actual_token
CONTAINER_UID=1000
GPU_DRIVER=cuda
```
## Even Moar Customizing!
See the `docker compose.yaml` file. The `command` instruction can be uncommented and used to run arbitrary startup commands. Some examples below.
### Reconfigure the runtime directory
Can be used to download additional models from the supported model list
In conjunction with `INVOKEAI_ROOT` can be also used to initialize a runtime directory
```
command:
- invokeai-configure
- --yes
```
Or install models:
```
command:
- invokeai-model-install
```

View File

@@ -1,11 +1,51 @@
#!/usr/bin/env bash
set -e
build_args=""
# If you want to build a specific flavor, set the CONTAINER_FLAVOR environment variable
# e.g. CONTAINER_FLAVOR=cpu ./build.sh
# Possible Values are:
# - cpu
# - cuda
# - rocm
# Don't forget to also set it when executing run.sh
# if it is not set, the script will try to detect the flavor by itself.
#
# Doc can be found here:
# https://invoke-ai.github.io/InvokeAI/installation/040_INSTALL_DOCKER/
[[ -f ".env" ]] && build_args=$(awk '$1 ~ /\=[^$]/ {print "--build-arg " $0 " "}' .env)
SCRIPTDIR=$(dirname "${BASH_SOURCE[0]}")
cd "$SCRIPTDIR" || exit 1
echo "docker-compose build args:"
echo $build_args
source ./env.sh
docker-compose build $build_args
DOCKERFILE=${INVOKE_DOCKERFILE:-./Dockerfile}
# print the settings
echo -e "You are using these values:\n"
echo -e "Dockerfile:\t\t${DOCKERFILE}"
echo -e "index-url:\t\t${PIP_EXTRA_INDEX_URL:-none}"
echo -e "Volumename:\t\t${VOLUMENAME}"
echo -e "Platform:\t\t${PLATFORM}"
echo -e "Container Registry:\t${CONTAINER_REGISTRY}"
echo -e "Container Repository:\t${CONTAINER_REPOSITORY}"
echo -e "Container Tag:\t\t${CONTAINER_TAG}"
echo -e "Container Flavor:\t${CONTAINER_FLAVOR}"
echo -e "Container Image:\t${CONTAINER_IMAGE}\n"
# Create docker volume
if [[ -n "$(docker volume ls -f name="${VOLUMENAME}" -q)" ]]; then
echo -e "Volume already exists\n"
else
echo -n "creating docker volume "
docker volume create "${VOLUMENAME}"
fi
# Build Container
docker build \
--platform="${PLATFORM:-linux/amd64}" \
--tag="${CONTAINER_IMAGE:-invokeai}" \
${CONTAINER_FLAVOR:+--build-arg="CONTAINER_FLAVOR=${CONTAINER_FLAVOR}"} \
${PIP_EXTRA_INDEX_URL:+--build-arg="PIP_EXTRA_INDEX_URL=${PIP_EXTRA_INDEX_URL}"} \
${PIP_PACKAGE:+--build-arg="PIP_PACKAGE=${PIP_PACKAGE}"} \
--file="${DOCKERFILE}" \
..

View File

@@ -1,48 +0,0 @@
# Copyright (c) 2023 Eugene Brodsky https://github.com/ebr
version: '3.8'
services:
invokeai:
image: "local/invokeai:latest"
# edit below to run on a container runtime other than nvidia-container-runtime.
# not yet tested with rocm/AMD GPUs
# Comment out the "deploy" section to run on CPU only
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
build:
context: ..
dockerfile: docker/Dockerfile
# variables without a default will automatically inherit from the host environment
environment:
- INVOKEAI_ROOT
- HF_HOME
# Create a .env file in the same directory as this docker-compose.yml file
# and populate it with environment variables. See .env.sample
env_file:
- .env
ports:
- "${INVOKEAI_PORT:-9090}:9090"
volumes:
- ${INVOKEAI_ROOT:-~/invokeai}:${INVOKEAI_ROOT:-/invokeai}
- ${HF_HOME:-~/.cache/huggingface}:${HF_HOME:-/invokeai/.cache/huggingface}
# - ${INVOKEAI_MODELS_DIR:-${INVOKEAI_ROOT:-/invokeai/models}}
# - ${INVOKEAI_MODELS_CONFIG_PATH:-${INVOKEAI_ROOT:-/invokeai/configs/models.yaml}}
tty: true
stdin_open: true
# # Example of running alternative commands/scripts in the container
# command:
# - bash
# - -c
# - |
# invokeai-model-install --yes --default-only --config_file ${INVOKEAI_ROOT}/config_custom.yaml
# invokeai-nodes-web --host 0.0.0.0

View File

@@ -1,65 +0,0 @@
#!/bin/bash
set -e -o pipefail
### Container entrypoint
# Runs the CMD as defined by the Dockerfile or passed to `docker run`
# Can be used to configure the runtime dir
# Bypass by using ENTRYPOINT or `--entrypoint`
### Set INVOKEAI_ROOT pointing to a valid runtime directory
# Otherwise configure the runtime dir first.
### Configure the InvokeAI runtime directory (done by default)):
# docker run --rm -it <this image> --configure
# or skip with --no-configure
### Set the CONTAINER_UID envvar to match your user.
# Ensures files created in the container are owned by you:
# docker run --rm -it -v /some/path:/invokeai -e CONTAINER_UID=$(id -u) <this image>
# Default UID: 1000 chosen due to popularity on Linux systems. Possibly 501 on MacOS.
USER_ID=${CONTAINER_UID:-1000}
USER=invoke
usermod -u ${USER_ID} ${USER} 1>/dev/null
configure() {
# Configure the runtime directory
if [[ -f ${INVOKEAI_ROOT}/invokeai.yaml ]]; then
echo "${INVOKEAI_ROOT}/invokeai.yaml exists. InvokeAI is already configured."
echo "To reconfigure InvokeAI, delete the above file."
echo "======================================================================"
else
mkdir -p ${INVOKEAI_ROOT}
chown --recursive ${USER} ${INVOKEAI_ROOT}
gosu ${USER} invokeai-configure --yes --default_only
fi
}
## Skip attempting to configure.
## Must be passed first, before any other args.
if [[ $1 != "--no-configure" ]]; then
configure
else
shift
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.
# (use SCP to copy files to/from the image, etc)
if [[ -v "PUBLIC_KEY" ]] && [[ ! -d "${HOME}/.ssh" ]]; then
apt-get update
apt-get install -y openssh-server
pushd $HOME
mkdir -p .ssh
echo ${PUBLIC_KEY} > .ssh/authorized_keys
chmod -R 700 .ssh
popd
service ssh start
fi
cd ${INVOKEAI_ROOT}
# Run the CMD as the Container User (not root).
exec gosu ${USER} "$@"

54
docker/env.sh Normal file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# This file is used to set environment variables for the build.sh and run.sh scripts.
# Try to detect the container flavor if no PIP_EXTRA_INDEX_URL got specified
if [[ -z "$PIP_EXTRA_INDEX_URL" ]]; then
# Activate virtual environment if not already activated and exists
if [[ -z $VIRTUAL_ENV ]]; then
[[ -e "$(dirname "${BASH_SOURCE[0]}")/../.venv/bin/activate" ]] \
&& source "$(dirname "${BASH_SOURCE[0]}")/../.venv/bin/activate" \
&& echo "Activated virtual environment: $VIRTUAL_ENV"
fi
# Decide which container flavor to build if not specified
if [[ -z "$CONTAINER_FLAVOR" ]] && python -c "import torch" &>/dev/null; then
# Check for CUDA and ROCm
CUDA_AVAILABLE=$(python -c "import torch;print(torch.cuda.is_available())")
ROCM_AVAILABLE=$(python -c "import torch;print(torch.version.hip is not None)")
if [[ "${CUDA_AVAILABLE}" == "True" ]]; then
CONTAINER_FLAVOR="cuda"
elif [[ "${ROCM_AVAILABLE}" == "True" ]]; then
CONTAINER_FLAVOR="rocm"
else
CONTAINER_FLAVOR="cpu"
fi
fi
# Set PIP_EXTRA_INDEX_URL based on container flavor
if [[ "$CONTAINER_FLAVOR" == "rocm" ]]; then
PIP_EXTRA_INDEX_URL="https://download.pytorch.org/whl/rocm"
elif [[ "$CONTAINER_FLAVOR" == "cpu" ]]; then
PIP_EXTRA_INDEX_URL="https://download.pytorch.org/whl/cpu"
# elif [[ -z "$CONTAINER_FLAVOR" || "$CONTAINER_FLAVOR" == "cuda" ]]; then
# PIP_PACKAGE=${PIP_PACKAGE-".[xformers]"}
fi
fi
# Variables shared by build.sh and run.sh
REPOSITORY_NAME="${REPOSITORY_NAME-$(basename "$(git rev-parse --show-toplevel)")}"
REPOSITORY_NAME="${REPOSITORY_NAME,,}"
VOLUMENAME="${VOLUMENAME-"${REPOSITORY_NAME}_data"}"
ARCH="${ARCH-$(uname -m)}"
PLATFORM="${PLATFORM-linux/${ARCH}}"
INVOKEAI_BRANCH="${INVOKEAI_BRANCH-$(git branch --show)}"
CONTAINER_REGISTRY="${CONTAINER_REGISTRY-"ghcr.io"}"
CONTAINER_REPOSITORY="${CONTAINER_REPOSITORY-"$(whoami)/${REPOSITORY_NAME}"}"
CONTAINER_FLAVOR="${CONTAINER_FLAVOR-cuda}"
CONTAINER_TAG="${CONTAINER_TAG-"${INVOKEAI_BRANCH##*/}-${CONTAINER_FLAVOR}"}"
CONTAINER_IMAGE="${CONTAINER_REGISTRY}/${CONTAINER_REPOSITORY}:${CONTAINER_TAG}"
CONTAINER_IMAGE="${CONTAINER_IMAGE,,}"
# enable docker buildkit
export DOCKER_BUILDKIT=1

View File

@@ -1,8 +1,41 @@
#!/usr/bin/env bash
set -e
# How to use: https://invoke-ai.github.io/InvokeAI/installation/040_INSTALL_DOCKER/
SCRIPTDIR=$(dirname "${BASH_SOURCE[0]}")
cd "$SCRIPTDIR" || exit 1
docker-compose up --build -d
docker-compose logs -f
source ./env.sh
# Create outputs directory if it does not exist
[[ -d ./outputs ]] || mkdir ./outputs
echo -e "You are using these values:\n"
echo -e "Volumename:\t${VOLUMENAME}"
echo -e "Invokeai_tag:\t${CONTAINER_IMAGE}"
echo -e "local Models:\t${MODELSPATH:-unset}\n"
docker run \
--interactive \
--tty \
--rm \
--platform="${PLATFORM}" \
--name="${REPOSITORY_NAME}" \
--hostname="${REPOSITORY_NAME}" \
--mount type=volume,volume-driver=local,source="${VOLUMENAME}",target=/data \
--mount type=bind,source="$(pwd)"/outputs/,target=/data/outputs/ \
${MODELSPATH:+--mount="type=bind,source=${MODELSPATH},target=/data/models"} \
${HUGGING_FACE_HUB_TOKEN:+--env="HUGGING_FACE_HUB_TOKEN=${HUGGING_FACE_HUB_TOKEN}"} \
--publish=9090:9090 \
--cap-add=sys_nice \
${GPU_FLAGS:+--gpus="${GPU_FLAGS}"} \
"${CONTAINER_IMAGE}" ${@:+$@}
echo -e "\nCleaning trash folder ..."
for f in outputs/.Trash*; do
if [ -e "$f" ]; then
rm -Rf "$f"
break
fi
done

View File

@@ -1,60 +0,0 @@
# InvokeAI - A Stable Diffusion Toolkit
Stable Diffusion distribution by InvokeAI: https://github.com/invoke-ai
The Docker image tracks the `main` branch of the InvokeAI project, which means it includes the latest features, but may contain some bugs.
Your working directory is mounted under the `/workspace` path inside the pod. The models are in `/workspace/invokeai/models`, and outputs are in `/workspace/invokeai/outputs`.
> **Only the /workspace directory will persist between pod restarts!**
> **If you _terminate_ (not just _stop_) the pod, the /workspace will be lost.**
## Quickstart
1. Launch a pod from this template. **It will take about 5-10 minutes to run through the initial setup**. Be patient.
1. Wait for the application to load.
- TIP: you know it's ready when the CPU usage goes idle
- You can also check the logs for a line that says "_Point your browser at..._"
1. Open the Invoke AI web UI: click the `Connect` => `connect over HTTP` button.
1. Generate some art!
## Other things you can do
At any point you may edit the pod configuration and set an arbitrary Docker command. For example, you could run a command to downloads some models using `curl`, or fetch some images and place them into your outputs to continue a working session.
If you need to run *multiple commands*, define them in the Docker Command field like this:
`bash -c "cd ${INVOKEAI_ROOT}/outputs; wormhole receive 2-foo-bar; invoke.py --web --host 0.0.0.0"`
### Copying your data in and out of the pod
This image includes a couple of handy tools to help you get the data into the pod (such as your custom models or embeddings), and out of the pod (such as downloading your outputs). Here are your options for getting your data in and out of the pod:
- **SSH server**:
1. Make sure to create and set your Public Key in the RunPod settings (follow the official instructions)
1. Add an exposed port 22 (TCP) in the pod settings!
1. When your pod restarts, you will see a new entry in the `Connect` dialog. Use this SSH server to `scp` or `sftp` your files as necessary, or SSH into the pod using the fully fledged SSH server.
- [**Magic Wormhole**](https://magic-wormhole.readthedocs.io/en/latest/welcome.html):
1. On your computer, `pip install magic-wormhole` (see above instructions for details)
1. Connect to the command line **using the "light" SSH client** or the browser-based console. _Currently there's a bug where `wormhole` isn't available when connected to "full" SSH server, as described above_.
1. `wormhole send /workspace/invokeai/outputs` will send the entire `outputs` directory. You can also send individual files.
1. Once packaged, you will see a `wormhole receive <123-some-words>` command. Copy it
1. Paste this command into the terminal on your local machine to securely download the payload.
1. It works the same in reverse: you can `wormhole send` some models from your computer to the pod. Again, save your files somewhere in `/workspace` or they will be lost when the pod is stopped.
- **RunPod's Cloud Sync feature** may be used to sync the persistent volume to cloud storage. You could, for example, copy the entire `/workspace` to S3, add some custom models to it, and copy it back from S3 when launching new pod configurations. Follow the Cloud Sync instructions.
### Disable the NSFW checker
The NSFW checker is enabled by default. To disable it, edit the pod configuration and set the following command:
```
invoke --web --host 0.0.0.0 --no-nsfw_checker
```
---
Template ©2023 Eugene Brodsky [ebr](https://github.com/ebr)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,521 +1,8 @@
# Invocations
Features in InvokeAI are added in the form of modular node-like systems called
**Invocations**.
An Invocation is simply a single operation that takes in some inputs and gives
out some outputs. We can then chain multiple Invocations together to create more
complex functionality.
## Invocations Directory
InvokeAI Invocations can be found in the `invokeai/app/invocations` directory.
You can add your new functionality to one of the existing Invocations in this
directory or create a new file in this directory as per your needs.
**Note:** _All Invocations must be inside this directory for InvokeAI to
recognize them as valid Invocations._
## Creating A New Invocation
In order to understand the process of creating a new Invocation, let us actually
create one.
In our example, let us create an Invocation that will take in an image, resize
it and output the resized image.
The first set of things we need to do when creating a new Invocation are -
- Create a new class that derives from a predefined parent class called
`BaseInvocation`.
- The name of every Invocation must end with the word `Invocation` in order for
it to be recognized as an Invocation.
- Every Invocation must have a `docstring` that describes what this Invocation
does.
- Every Invocation must have a unique `type` field defined which becomes its
indentifier.
- Invocations are strictly typed. We make use of the native
[typing](https://docs.python.org/3/library/typing.html) library and the
installed [pydantic](https://pydantic-docs.helpmanual.io/) library for
validation.
So let us do that.
```python
from typing import Literal
from .baseinvocation import BaseInvocation
class ResizeInvocation(BaseInvocation):
'''Resizes an image'''
type: Literal['resize'] = 'resize'
```
That's great.
Now we have setup the base of our new Invocation. Let us think about what inputs
our Invocation takes.
- We need an `image` that we are going to resize.
- We will need new `width` and `height` values to which we need to resize the
image to.
### **Inputs**
Every Invocation input is a pydantic `Field` and like everything else should be
strictly typed and defined.
So let us create these inputs for our Invocation. First up, the `image` input we
need. Generally, we can use standard variable types in Python but InvokeAI
already has a custom `ImageField` type that handles all the stuff that is needed
for image inputs.
But what is this `ImageField` ..? It is a special class type specifically
written to handle how images are dealt with in InvokeAI. We will cover how to
create your own custom field types later in this guide. For now, let's go ahead
and use it.
```python
from typing import Literal, Union
from pydantic import Field
from .baseinvocation import BaseInvocation
from ..models.image import ImageField
class ResizeInvocation(BaseInvocation):
'''Resizes an image'''
type: Literal['resize'] = 'resize'
# Inputs
image: Union[ImageField, None] = Field(description="The input image", default=None)
```
Let us break down our input code.
```python
image: Union[ImageField, None] = Field(description="The input image", default=None)
```
| Part | Value | Description |
| --------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| Name | `image` | The variable that will hold our image |
| Type Hint | `Union[ImageField, None]` | The types for our field. Indicates that the image can either be an `ImageField` type or `None` |
| Field | `Field(description="The input image", default=None)` | The image variable is a field which needs a description and a default value that we set to `None`. |
Great. Now let us create our other inputs for `width` and `height`
```python
from typing import Literal, Union
from pydantic import Field
from .baseinvocation import BaseInvocation
from ..models.image import ImageField
class ResizeInvocation(BaseInvocation):
'''Resizes an image'''
type: Literal['resize'] = 'resize'
# Inputs
image: Union[ImageField, None] = Field(description="The input image", default=None)
width: int = Field(default=512, ge=64, le=2048, description="Width of the new image")
height: int = Field(default=512, ge=64, le=2048, description="Height of the new image")
```
As you might have noticed, we added two new parameters to the field type for
`width` and `height` called `gt` and `le`. These basically stand for _greater
than or equal to_ and _less than or equal to_. There are various other param
types for field that you can find on the **pydantic** documentation.
**Note:** _Any time it is possible to define constraints for our field, we
should do it so the frontend has more information on how to parse this field._
Perfect. We now have our inputs. Let us do something with these.
### **Invoke Function**
The `invoke` function is where all the magic happens. This function provides you
the `context` parameter that is of the type `InvocationContext` which will give
you access to the current context of the generation and all the other services
that are provided by it by InvokeAI.
Let us create this function first.
```python
from typing import Literal, Union
from pydantic import Field
from .baseinvocation import BaseInvocation, InvocationContext
from ..models.image import ImageField
class ResizeInvocation(BaseInvocation):
'''Resizes an image'''
type: Literal['resize'] = 'resize'
# Inputs
image: Union[ImageField, None] = Field(description="The input image", default=None)
width: int = Field(default=512, ge=64, le=2048, description="Width of the new image")
height: int = Field(default=512, ge=64, le=2048, description="Height of the new image")
def invoke(self, context: InvocationContext):
pass
```
### **Outputs**
The output of our Invocation will be whatever is returned by this `invoke`
function. Like with our inputs, we need to strongly type and define our outputs
too.
What is our output going to be? Another image. Normally you'd have to create a
type for this but InvokeAI already offers you an `ImageOutput` type that handles
all the necessary info related to image outputs. So let us use that.
We will cover how to create your own output types later in this guide.
```python
from typing import Literal, Union
from pydantic import Field
from .baseinvocation import BaseInvocation, InvocationContext
from ..models.image import ImageField
from .image import ImageOutput
class ResizeInvocation(BaseInvocation):
'''Resizes an image'''
type: Literal['resize'] = 'resize'
# Inputs
image: Union[ImageField, None] = Field(description="The input image", default=None)
width: int = Field(default=512, ge=64, le=2048, description="Width of the new image")
height: int = Field(default=512, ge=64, le=2048, description="Height of the new image")
def invoke(self, context: InvocationContext) -> ImageOutput:
pass
```
Perfect. Now that we have our Invocation setup, let us do what we want to do.
- We will first load the image. Generally we do this using the `PIL` library but
we can use one of the services provided by InvokeAI to load the image.
- We will resize the image using `PIL` to our input data.
- We will output this image in the format we set above.
So let's do that.
```python
from typing import Literal, Union
from pydantic import Field
from .baseinvocation import BaseInvocation, InvocationContext
from ..models.image import ImageField, ResourceOrigin, ImageCategory
from .image import ImageOutput
class ResizeInvocation(BaseInvocation):
'''Resizes an image'''
type: Literal['resize'] = 'resize'
# Inputs
image: Union[ImageField, None] = Field(description="The input image", default=None)
width: int = Field(default=512, ge=64, le=2048, description="Width of the new image")
height: int = Field(default=512, ge=64, le=2048, description="Height of the new image")
def invoke(self, context: InvocationContext) -> ImageOutput:
# Load the image using InvokeAI's predefined Image Service.
image = context.services.images.get_pil_image(self.image.image_origin, self.image.image_name)
# Resizing the image
# Because we used the above service, we already have a PIL image. So we can simply resize.
resized_image = image.resize((self.width, self.height))
# Preparing the image for output using InvokeAI's predefined Image Service.
output_image = context.services.images.create(
image=resized_image,
image_origin=ResourceOrigin.INTERNAL,
image_category=ImageCategory.GENERAL,
node_id=self.id,
session_id=context.graph_execution_state_id,
is_intermediate=self.is_intermediate,
)
# Returning the Image
return ImageOutput(
image=ImageField(
image_name=output_image.image_name,
image_origin=output_image.image_origin,
),
width=output_image.width,
height=output_image.height,
)
```
**Note:** Do not be overwhelmed by the `ImageOutput` process. InvokeAI has a
certain way that the images need to be dispatched in order to be stored and read
correctly. In 99% of the cases when dealing with an image output, you can simply
copy-paste the template above.
That's it. You made your own **Resize Invocation**.
## Result
Once you make your Invocation correctly, the rest of the process is fully
automated for you.
When you launch InvokeAI, you can go to `http://localhost:9090/docs` and see
your new Invocation show up there with all the relevant info.
![resize invocation](../assets/contributing/resize_invocation.png)
When you launch the frontend UI, you can go to the Node Editor tab and find your
new Invocation ready to be used.
![resize node editor](../assets/contributing/resize_node_editor.png)
# Advanced
## Custom Input Fields
Now that you know how to create your own Invocations, let us dive into slightly
more advanced topics.
While creating your own Invocations, you might run into a scenario where the
existing input types in InvokeAI do not meet your requirements. In such cases,
you can create your own input types.
Let us create one as an example. Let us say we want to create a color input
field that represents a color code. But before we start on that here are some
general good practices to keep in mind.
**Good Practices**
- There is no naming convention for input fields but we highly recommend that
you name it something appropriate like `ColorField`.
- It is not mandatory but it is heavily recommended to add a relevant
`docstring` to describe your input field.
- Keep your field in the same file as the Invocation that it is made for or in
another file where it is relevant.
All input types a class that derive from the `BaseModel` type from `pydantic`.
So let's create one.
```python
from pydantic import BaseModel
class ColorField(BaseModel):
'''A field that holds the rgba values of a color'''
pass
```
Perfect. Now let us create our custom inputs for our field. This is exactly
similar how you created input fields for your Invocation. All the same rules
apply. Let us create four fields representing the _red(r)_, _blue(b)_,
_green(g)_ and _alpha(a)_ channel of the color.
```python
class ColorField(BaseModel):
'''A field that holds the rgba values of a color'''
r: int = Field(ge=0, le=255, description="The red channel")
g: int = Field(ge=0, le=255, description="The green channel")
b: int = Field(ge=0, le=255, description="The blue channel")
a: int = Field(ge=0, le=255, description="The alpha channel")
```
That's it. We now have a new input field type that we can use in our Invocations
like this.
```python
color: ColorField = Field(default=ColorField(r=0, g=0, b=0, a=0), description='Background color of an image')
```
**Extra Config**
All input fields also take an additional `Config` class that you can use to do
various advanced things like setting required parameters and etc.
Let us do that for our _ColorField_ and enforce all the values because we did
not define any defaults for our fields.
```python
class ColorField(BaseModel):
'''A field that holds the rgba values of a color'''
r: int = Field(ge=0, le=255, description="The red channel")
g: int = Field(ge=0, le=255, description="The green channel")
b: int = Field(ge=0, le=255, description="The blue channel")
a: int = Field(ge=0, le=255, description="The alpha channel")
class Config:
schema_extra = {"required": ["r", "g", "b", "a"]}
```
Now it becomes mandatory for the user to supply all the values required by our
input field.
We will discuss the `Config` class in extra detail later in this guide and how
you can use it to make your Invocations more robust.
## Custom Output Types
Like with custom inputs, sometimes you might find yourself needing custom
outputs that InvokeAI does not provide. We can easily set one up.
Now that you are familiar with Invocations and Inputs, let us use that knowledge
to put together a custom output type for an Invocation that returns _width_,
_height_ and _background_color_ that we need to create a blank image.
- A custom output type is a class that derives from the parent class of
`BaseInvocationOutput`.
- It is not mandatory but we recommend using names ending with `Output` for
output types. So we'll call our class `BlankImageOutput`
- It is not mandatory but we highly recommend adding a `docstring` to describe
what your output type is for.
- Like Invocations, each output type should have a `type` variable that is
**unique**
Now that we know the basic rules for creating a new output type, let us go ahead
and make it.
```python
from typing import Literal
from pydantic import Field
from .baseinvocation import BaseInvocationOutput
class BlankImageOutput(BaseInvocationOutput):
'''Base output type for creating a blank image'''
type: Literal['blank_image_output'] = 'blank_image_output'
# Inputs
width: int = Field(description='Width of blank image')
height: int = Field(description='Height of blank image')
bg_color: ColorField = Field(description='Background color of blank image')
class Config:
schema_extra = {"required": ["type", "width", "height", "bg_color"]}
```
All set. We now have an output type that requires what we need to create a
blank_image. And if you noticed it, we even used the `Config` class to ensure
the fields are required.
## Custom Configuration
As you might have noticed when making inputs and outputs, we used a class called
`Config` from _pydantic_ to further customize them. Because our inputs and
outputs essentially inherit from _pydantic_'s `BaseModel` class, all
[configuration options](https://docs.pydantic.dev/latest/usage/schema/#schema-customization)
that are valid for _pydantic_ classes are also valid for our inputs and outputs.
You can do the same for your Invocations too but InvokeAI makes our life a
little bit easier on that end.
InvokeAI provides a custom configuration class called `InvocationConfig`
particularly for configuring Invocations. This is exactly the same as the raw
`Config` class from _pydantic_ with some extra stuff on top to help faciliate
parsing of the scheme in the frontend UI.
At the current moment, tihs `InvocationConfig` class is further improved with
the following features related the `ui`.
| Config Option | Field Type | Example |
| ------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| type_hints | `Dict[str, Literal["integer", "float", "boolean", "string", "enum", "image", "latents", "model", "control"]]` | `type_hint: "model"` provides type hints related to the model like displaying a list of available models |
| tags | `List[str]` | `tags: ['resize', 'image']` will classify your invocation under the tags of resize and image. |
| title | `str` | `title: 'Resize Image` will rename your to this custom title rather than infer from the name of the Invocation class. |
So let us update your `ResizeInvocation` with some extra configuration and see
how that works.
```python
from typing import Literal, Union
from pydantic import Field
from .baseinvocation import BaseInvocation, InvocationContext, InvocationConfig
from ..models.image import ImageField, ResourceOrigin, ImageCategory
from .image import ImageOutput
class ResizeInvocation(BaseInvocation):
'''Resizes an image'''
type: Literal['resize'] = 'resize'
# Inputs
image: Union[ImageField, None] = Field(description="The input image", default=None)
width: int = Field(default=512, ge=64, le=2048, description="Width of the new image")
height: int = Field(default=512, ge=64, le=2048, description="Height of the new image")
class Config(InvocationConfig):
schema_extra: {
ui: {
tags: ['resize', 'image'],
title: ['My Custom Resize']
}
}
def invoke(self, context: InvocationContext) -> ImageOutput:
# Load the image using InvokeAI's predefined Image Service.
image = context.services.images.get_pil_image(self.image.image_origin, self.image.image_name)
# Resizing the image
# Because we used the above service, we already have a PIL image. So we can simply resize.
resized_image = image.resize((self.width, self.height))
# Preparing the image for output using InvokeAI's predefined Image Service.
output_image = context.services.images.create(
image=resized_image,
image_origin=ResourceOrigin.INTERNAL,
image_category=ImageCategory.GENERAL,
node_id=self.id,
session_id=context.graph_execution_state_id,
is_intermediate=self.is_intermediate,
)
# Returning the Image
return ImageOutput(
image=ImageField(
image_name=output_image.image_name,
image_origin=output_image.image_origin,
),
width=output_image.width,
height=output_image.height,
)
```
We now customized our code to let the frontend know that our Invocation falls
under `resize` and `image` categories. So when the user searches for these
particular words, our Invocation will show up too.
We also set a custom title for our Invocation. So instead of being called
`Resize`, it will be called `My Custom Resize`.
As simple as that.
As time goes by, InvokeAI will further improve and add more customizability for
Invocation configuration. We will have more documentation regarding this at a
later time.
# **[TODO]**
## Custom Components For Frontend
Every backend input type should have a corresponding frontend component so the
UI knows what to render when you use a particular field type.
If you are using existing field types, we already have components for those. So
you don't have to worry about creating anything new. But this might not always
be the case. Sometimes you might want to create new field types and have the
frontend UI deal with it in a different way.
This is where we venture into the world of React and Javascript and create our
own new components for our Invocations. Do not fear the world of JS. It's
actually pretty straightforward.
Let us create a new component for our custom color field we created above. When
we use a color field, let us say we want the UI to display a color picker for
the user to pick from rather than entering values. That is what we will build
now.
---
# OLD -- TO BE DELETED OR MOVED LATER
---
Invocations represent a single operation, its inputs, and its outputs. These
operations and their outputs can be chained together to generate and modify
images.
## Creating a new invocation

View File

@@ -1,12 +1,9 @@
---
title: Concepts
title: Concepts Library
---
# :material-library-shelves: The Hugging Face Concepts Library and Importing Textual Inversion files
With the advances in research, many new capabilities are available to customize the knowledge and understanding of novel concepts not originally contained in the base model.
## Using Textual Inversion Files
Textual inversion (TI) files are small models that customize the output of
@@ -15,16 +12,18 @@ and artistic styles. They are also known as "embeds" in the machine learning
world.
Each TI file introduces one or more vocabulary terms to the SD model. These are
known in InvokeAI as "triggers." Triggers are denoted using angle brackets
as in "&lt;trigger-phrase&gt;". The two most common type of
known in InvokeAI as "triggers." Triggers are often, but not always, denoted
using angle brackets as in "&lt;trigger-phrase&gt;". The two most common type of
TI files that you'll encounter are `.pt` and `.bin` files, which are produced by
different TI training packages. InvokeAI supports both formats, but its
[built-in TI training system](TRAINING.md) produces `.pt`.
[built-in TI training system](TEXTUAL_INVERSION.md) produces `.pt`.
The [Hugging Face company](https://huggingface.co/sd-concepts-library) has
amassed a large ligrary of &gt;800 community-contributed TI files covering a
broad range of subjects and styles. You can also install your own or others' TI files
by placing them in the designated directory for the compatible model type
broad range of subjects and styles. InvokeAI has built-in support for this
library which downloads and merges TI files automatically upon request. You can
also install your own or others' TI files by placing them in a designated
directory.
### An Example
@@ -42,43 +41,66 @@ You can also combine styles and concepts:
| :--------------------------------------------------------: |
| ![](../assets/concepts/image5.png) |
</figure>
## Using a Hugging Face Concept
!!! warning "Authenticating to HuggingFace"
Some concepts require valid authentication to HuggingFace. Without it, they will not be downloaded
and will be silently ignored.
If you used an installer to install InvokeAI, you may have already set a HuggingFace token.
If you skipped this step, you can:
- run the InvokeAI configuration script again (if you used a manual installer): `invokeai-configure`
- set one of the `HUGGINGFACE_TOKEN` or `HUGGING_FACE_HUB_TOKEN` environment variables to contain your token
Finally, if you already used any HuggingFace library on your computer, you might already have a token
in your local cache. Check for a hidden `.huggingface` directory in your home folder. If it
contains a `token` file, then you are all set.
Hugging Face TI concepts are downloaded and installed automatically as you
require them. This requires your machine to be connected to the Internet. To
find out what each concept is for, you can browse the
[Hugging Face concepts library](https://huggingface.co/sd-concepts-library) and
look at examples of what each concept produces.
To load concepts, you will need to open the Web UI's configuration
dialogue and activate "Show Textual Inversions from HF Concepts
Library". This will then add a list of HF Concepts to the dropdown
"Add Textual Inversion" menu. Select the concept(s) of your choice and
they will be incorporated into the positive prompt. A few concepts are
designed for the negative prompt, in which case you can add them to
the negative prompt box by select the down arrow icon next to the
textual inversion menu.
There are nearly 1000 HF concepts, more than will fit into a menu. For
this reason we only show the most popular concepts (those which have
received 5 or more likes). If you wish to use a concept that is not on
the list, you may simply type its name surrounded by brackets. For
example, to load the concept named "xidiversity", add `<xidiversity>`
to the positive or negative prompt text.
## Installing your Own TI Files
You may install any number of `.pt` and `.bin` files simply by copying them into
the `embedding` directory of the corresponding InvokeAI models directory (usually `invokeai`
in your home directory). For example, you can simply move a Stable Diffusion 1.5 embedding file to
the `sd-1/embedding` folder. Be careful not to overwrite one file with another.
the `embeddings` directory of the InvokeAI runtime directory (usually `invokeai`
in your home directory). You may create subdirectories in order to organize the
files in any way you wish. Be careful not to overwrite one file with another.
For example, TI files generated by the Hugging Face toolkit share the named
`learned_embedding.bin`. You can rename these, or use subdirectories to keep them distinct.
`learned_embedding.bin`. You can use subdirectories to keep them distinct.
At startup time, InvokeAI will scan the various `embedding` directories and load any TI
files it finds there for compatible models. At startup you will see a message similar to this one:
At startup time, InvokeAI will scan the `embeddings` directory and load any TI
files it finds there. At startup you will see a message similar to this one:
```bash
>> Current embedding manager terms: <HOI4-Leader>, <princess-knight>
```
To use these when generating, simply type the `<` key in your prompt to open the Textual Inversion WebUI and
select the embedding you'd like to use. This UI has type-ahead support, so you can easily find supported embeddings.
## Using LoRAs
The terms you can use will appear in the "Add Textual Inversion"
dropdown menu above the HF Concepts.
LoRA files are models that customize the output of Stable Diffusion image generation.
Larger than embeddings, but much smaller than full models, they augment SD with improved
understanding of subjects and artistic styles.
Unlike TI files, LoRAs do not introduce novel vocabulary into the model's known tokens. Instead,
LoRAs augment the model's weights that are applied to generate imagery. LoRAs may be supplied
with a "trigger" word that they have been explicitly trained on, or may simply apply their
effect without being triggered.
LoRAs are typically stored in .safetensors files, which are the most secure way to store and transmit
these types of weights. You may install any number of `.safetensors` LoRA files simply by copying them into
the `lora` directory of the corresponding InvokeAI models directory (usually `invokeai`
in your home directory). For example, you can simply move a Stable Diffusion 1.5 LoRA file to
the `sd-1/lora` folder.
To use these when generating, open the LoRA menu item in the options panel, select the LoRAs you want to apply
and ensure that they have the appropriate weight recommended by the model provider. Typically, most LoRAs perform best at a weight of .75-1.
## Further Reading
Please see [the repository](https://github.com/rinongal/textual_inversion) and
associated paper for details and limitations.

View File

@@ -301,48 +301,5 @@ summoning up the concept of some sort of scifi creature? Let's find out.
Indeed, removing the word "hybrid" produces an image that is more like what we'd
expect.
## Dynamic Prompts
Dynamic Prompts are a powerful feature designed to produce a variety of prompts based on user-defined options. Using a special syntax, you can construct a prompt with multiple possibilities, and the system will automatically generate a series of permutations based on your settings. This is extremely beneficial for ideation, exploring various scenarios, or testing different concepts swiftly and efficiently.
### Structure of a Dynamic Prompt
A Dynamic Prompt comprises of regular text, supplemented with alternatives enclosed within curly braces {} and separated by a vertical bar |. For example: {option1|option2|option3}. The system will then select one of the options to include in the final prompt. This flexible system allows for options to be placed throughout the text as needed.
Furthermore, Dynamic Prompts can designate multiple selections from a single group of options. This feature is triggered by prefixing the options with a numerical value followed by $$. For example, in {2$$option1|option2|option3}, the system will select two distinct options from the set.
### Creating Dynamic Prompts
To create a Dynamic Prompt, follow these steps:
Draft your sentence or phrase, identifying words or phrases with multiple possible options.
Encapsulate the different options within curly braces {}.
Within the braces, separate each option using a vertical bar |.
If you want to include multiple options from a single group, prefix with the desired number and $$.
For instance: A {house|apartment|lodge|cottage} in {summer|winter|autumn|spring} designed in {2$$style1|style2|style3}.
### How Dynamic Prompts Work
Once a Dynamic Prompt is configured, the system generates an array of combinations using the options provided. Each group of options in curly braces is treated independently, with the system selecting one option from each group. For a prefixed set (e.g., 2$$), the system will select two distinct options.
For example, the following prompts could be generated from the above Dynamic Prompt:
A house in summer designed in style1, style2
A lodge in autumn designed in style3, style1
A cottage in winter designed in style2, style3
And many more!
When the `Combinatorial` setting is on, Invoke will disable the "Images" selection, and generate every combination up until the setting for Max Prompts is reached.
When the `Combinatorial` setting is off, Invoke will randomly generate combinations up until the setting for Images has been reached.
### Tips and Tricks for Using Dynamic Prompts
Below are some useful strategies for creating Dynamic Prompts:
Utilize Dynamic Prompts to generate a wide spectrum of prompts, perfect for brainstorming and exploring diverse ideas.
Ensure that the options within a group are contextually relevant to the part of the sentence where they are used. For instance, group building types together, and seasons together.
Apply the 2$$ prefix when you want to incorporate more than one option from a single group. This becomes quite handy when mixing and matching different elements.
Experiment with different quantities for the prefix. For example, 3$$ will select three distinct options.
Be aware of coherence in your prompts. Although the system can generate all possible combinations, not all may semantically make sense. Therefore, carefully choose the options for each group.
Always review and fine-tune the generated prompts as needed. While Dynamic Prompts can help you generate a multitude of combinations, the final polishing and refining remain in your hands.
In conclusion, prompt blending is great for exploring creative space,
but takes some trial and error to achieve the desired effect.

View File

@@ -1,10 +1,9 @@
---
title: Training
title: Textual-Inversion
---
# :material-file-document: Training
# :material-file-document: Textual Inversion
# Textual Inversion Training
## **Personalizing Text-to-Image Generation**
You may personalize the generated images to provide your own styles or objects
@@ -259,6 +258,16 @@ invokeai-ti \
--only_save_embeds
```
## Using Embeddings
After training completes, the resultant embeddings will be saved into your `$INVOKEAI_ROOT/embeddings/<trigger word>/learned_embeds.bin`.
These will be automatically loaded when you start InvokeAI.
Add the trigger word, surrounded by angle brackets, to use that embedding. For example, if your trigger word was `terence`, use `<terence>` in prompts. This is the same syntax used by the HuggingFace concepts library.
**Note:** `.pt` embeddings do not require the angle brackets.
## Troubleshooting
### `Cannot load embedding for <trigger>. It was trained on a model with token dimension 1024, but the current model has token dimension 768`

View File

@@ -248,7 +248,6 @@ class InvokeAiInstance:
"install",
"--require-virtualenv",
"torch~=2.0.0",
"torchmetrics==0.11.4",
"torchvision>=0.14.1",
"--force-reinstall",
"--find-links" if find_links is not None else None,

View File

@@ -20,7 +20,7 @@ echo 9. Update InvokeAI
echo 10. Command-line help
echo Q - Quit
set /P choice="Please enter 1-10, Q: [2] "
if not defined choice set choice=1
if not defined choice set choice=2
IF /I "%choice%" == "1" (
echo Starting the InvokeAI browser-based UI..
python .venv\Scripts\invokeai-web.exe %*
@@ -56,7 +56,7 @@ IF /I "%choice%" == "1" (
call cmd /k
) ELSE IF /I "%choice%" == "9" (
echo Running invokeai-update...
python -m invokeai.frontend.install.invokeai_update
python .venv\Scripts\invokeai-update.exe %*
) ELSE IF /I "%choice%" == "10" (
echo Displaying command line help...
python .venv\Scripts\invokeai.exe --help %*

View File

@@ -93,7 +93,7 @@ do_choice() {
9)
clear
printf "Update InvokeAI\n"
python -m invokeai.frontend.install.invokeai_update
invokeai-update
;;
10)
clear

View File

@@ -17,7 +17,6 @@ from invokeai.app.services.metadata import CoreMetadataService
from invokeai.app.services.resource_name import SimpleNameService
from invokeai.app.services.urls import LocalUrlService
from invokeai.backend.util.logging import InvokeAILogger
from invokeai.version.invokeai_version import __version__
from ..services.default_graphs import create_system_graphs
from ..services.latent_storage import DiskLatentsStorage, ForwardCacheLatentsStorage
@@ -59,8 +58,7 @@ class ApiDependencies:
@staticmethod
def initialize(config, event_handler_id: int, logger: Logger = logger):
logger.debug(f'InvokeAI version {__version__}')
logger.debug(f"Internet connectivity is {config.internet_available}")
logger.info(f"Internet connectivity is {config.internet_available}")
events = FastAPIEventService(event_handler_id)

View File

@@ -1,9 +1,9 @@
from fastapi import Body, HTTPException, Path
from fastapi import Body, HTTPException, Path, Query
from fastapi.routing import APIRouter
from invokeai.app.models.image import (AddManyImagesToBoardResult,
GetAllBoardImagesForBoardResult,
RemoveManyImagesFromBoardResult)
from invokeai.app.services.board_record_storage import BoardRecord, BoardChanges
from invokeai.app.services.image_record_storage import OffsetPaginatedResults
from invokeai.app.services.models.board_record import BoardDTO
from invokeai.app.services.models.image_record import ImageDTO
from ..dependencies import ApiDependencies
@@ -11,7 +11,7 @@ board_images_router = APIRouter(prefix="/v1/board_images", tags=["boards"])
@board_images_router.post(
"/{board_id}",
"/",
operation_id="create_board_image",
responses={
201: {"description": "The image was added to a board successfully"},
@@ -19,19 +19,16 @@ board_images_router = APIRouter(prefix="/v1/board_images", tags=["boards"])
status_code=201,
)
async def create_board_image(
board_id: str = Path(description="The id of the board to add to"),
board_id: str = Body(description="The id of the board to add to"),
image_name: str = Body(description="The name of the image to add"),
):
"""Creates a board_image"""
try:
result = ApiDependencies.invoker.services.board_images.add_image_to_board(
board_id=board_id, image_name=image_name
)
result = ApiDependencies.invoker.services.board_images.add_image_to_board(board_id=board_id, image_name=image_name)
return result
except Exception as e:
raise HTTPException(status_code=500, detail="Failed to add to board")
@board_images_router.delete(
"/",
operation_id="remove_board_image",
@@ -41,78 +38,32 @@ async def create_board_image(
status_code=201,
)
async def remove_board_image(
image_name: str = Body(
description="The name of the image to remove from its board"
),
board_id: str = Body(description="The id of the board"),
image_name: str = Body(description="The name of the image to remove"),
):
"""Deletes a board_image"""
try:
result = ApiDependencies.invoker.services.board_images.remove_image_from_board(
image_name=image_name
)
result = ApiDependencies.invoker.services.board_images.remove_image_from_board(board_id=board_id, image_name=image_name)
return result
except Exception as e:
raise HTTPException(status_code=500, detail="Failed to update board")
@board_images_router.get(
"/{board_id}",
operation_id="get_all_board_images_for_board",
response_model=GetAllBoardImagesForBoardResult,
operation_id="list_board_images",
response_model=OffsetPaginatedResults[ImageDTO],
)
async def get_all_board_images_for_board(
async def list_board_images(
board_id: str = Path(description="The id of the board"),
) -> GetAllBoardImagesForBoardResult:
"""Gets all image names for a board"""
offset: int = Query(default=0, description="The page offset"),
limit: int = Query(default=10, description="The number of boards per page"),
) -> OffsetPaginatedResults[ImageDTO]:
"""Gets a list of images for a board"""
result = (
ApiDependencies.invoker.services.board_images.get_all_board_images_for_board(
board_id,
)
)
return result
@board_images_router.patch(
"/{board_id}/images",
operation_id="create_multiple_board_images",
responses={
201: {"description": "The images were added to the board successfully"},
},
status_code=201,
)
async def create_multiple_board_images(
board_id: str = Path(description="The id of the board"),
image_names: list[str] = Body(
description="The names of the images to add to the board"
),
) -> AddManyImagesToBoardResult:
"""Add many images to a board"""
results = ApiDependencies.invoker.services.board_images.add_many_images_to_board(
board_id, image_names
results = ApiDependencies.invoker.services.board_images.get_images_for_board(
board_id,
)
return results
@board_images_router.post(
"/images",
operation_id="delete_multiple_board_images",
responses={
201: {"description": "The images were removed from their boards successfully"},
},
status_code=201,
)
async def delete_multiple_board_images(
image_names: list[str] = Body(
description="The names of the images to remove from their boards, if they have one"
),
) -> RemoveManyImagesFromBoardResult:
"""Remove many images from their boards, if they have one"""
results = (
ApiDependencies.invoker.services.board_images.remove_many_images_from_board(
image_names
)
)
return results

View File

@@ -1,13 +1,11 @@
from typing import Optional, Union
from fastapi import Body, HTTPException, Path, Query
from fastapi.routing import APIRouter
from invokeai.app.models.image import DeleteManyImagesResult
from invokeai.app.services.board_record_storage import BoardChanges
from invokeai.app.services.image_record_storage import OffsetPaginatedResults
from invokeai.app.services.models.board_record import BoardDTO
from ..dependencies import ApiDependencies
boards_router = APIRouter(prefix="/v1/boards", tags=["boards"])
@@ -71,26 +69,25 @@ async def update_board(
raise HTTPException(status_code=500, detail="Failed to update board")
@boards_router.delete("/{board_id}", operation_id="delete_board", response_model=DeleteManyImagesResult)
@boards_router.delete("/{board_id}", operation_id="delete_board")
async def delete_board(
board_id: str = Path(description="The id of board to delete"),
include_images: Optional[bool] = Query(
description="Permanently delete all images on the board", default=False
),
) -> DeleteManyImagesResult:
) -> None:
"""Deletes a board"""
try:
if include_images is True:
result = ApiDependencies.invoker.services.images.delete_images_on_board(
ApiDependencies.invoker.services.images.delete_images_on_board(
board_id=board_id
)
ApiDependencies.invoker.services.boards.delete(board_id=board_id)
else:
ApiDependencies.invoker.services.boards.delete(board_id=board_id)
result = DeleteManyImagesResult(deleted_images=[])
return result
except Exception as e:
raise HTTPException(status_code=500, detail="Failed to delete images on board")
# TODO: Does this need any exception handling at all?
pass
@boards_router.get(

View File

@@ -1,19 +1,20 @@
import io
from typing import Optional
from fastapi import (Body, HTTPException, Path, Query, Request, Response,
UploadFile)
from fastapi.responses import FileResponse
from fastapi import Body, HTTPException, Path, Query, Request, Response, UploadFile
from fastapi.routing import APIRouter
from fastapi.responses import FileResponse
from PIL import Image
from invokeai.app.models.image import (DeleteManyImagesResult, ImageCategory,
ResourceOrigin)
from invokeai.app.models.image import (
ImageCategory,
ResourceOrigin,
)
from invokeai.app.services.image_record_storage import OffsetPaginatedResults
from invokeai.app.services.models.image_record import (GetImagesByNamesResult,
ImageDTO,
ImageRecordChanges,
ImageUrlsDTO)
from invokeai.app.services.models.image_record import (
ImageDTO,
ImageRecordChanges,
ImageUrlsDTO,
)
from invokeai.app.services.item_storage import PaginatedResults
from ..dependencies import ApiDependencies
@@ -21,7 +22,7 @@ images_router = APIRouter(prefix="/v1/images", tags=["images"])
@images_router.post(
"/upload",
"/",
operation_id="upload_image",
responses={
201: {"description": "The image was uploaded successfully"},
@@ -102,14 +103,14 @@ async def update_image(
@images_router.get(
"/{image_name}",
operation_id="get_image",
"/{image_name}/metadata",
operation_id="get_image_metadata",
response_model=ImageDTO,
)
async def get_image_dto(
async def get_image_metadata(
image_name: str = Path(description="The name of image to get"),
) -> ImageDTO:
"""Gets an image's DTO"""
"""Gets an image's metadata"""
try:
return ApiDependencies.invoker.services.images.get_dto(image_name)
@@ -118,8 +119,8 @@ async def get_image_dto(
@images_router.get(
"/{image_name}/full_size",
operation_id="get_image_full_size",
"/{image_name}",
operation_id="get_image_full",
response_class=Response,
responses={
200: {
@@ -129,7 +130,7 @@ async def get_image_dto(
404: {"description": "Image not found"},
},
)
async def get_image_full_size(
async def get_image_full(
image_name: str = Path(description="The name of full-resolution image file to get"),
) -> FileResponse:
"""Gets a full-resolution image file"""
@@ -207,10 +208,10 @@ async def get_image_urls(
@images_router.get(
"/",
operation_id="get_many_images",
operation_id="list_images_with_metadata",
response_model=OffsetPaginatedResults[ImageDTO],
)
async def get_many_images(
async def list_images_with_metadata(
image_origin: Optional[ResourceOrigin] = Query(
default=None, description="The origin of images to list"
),
@@ -221,8 +222,7 @@ async def get_many_images(
default=None, description="Whether to list intermediate images"
),
board_id: Optional[str] = Query(
default=None,
description="The board id to filter by, provide 'none' for images without a board",
default=None, description="The board id to filter by"
),
offset: int = Query(default=0, description="The page offset"),
limit: int = Query(default=10, description="The number of images per page"),
@@ -239,36 +239,3 @@ async def get_many_images(
)
return image_dtos
@images_router.post(
"/",
operation_id="get_images_by_names",
response_model=GetImagesByNamesResult,
)
async def get_images_by_names(
image_names: list[str] = Body(description="The names of the images to get"),
) -> GetImagesByNamesResult:
"""Gets a list of images"""
result = ApiDependencies.invoker.services.images.get_images_by_names(
image_names
)
return result
@images_router.post(
"/delete",
operation_id="delete_many_images",
response_model=DeleteManyImagesResult,
)
async def delete_many_images(
image_names: list[str] = Body(description="The names of the images to delete"),
) -> DeleteManyImagesResult:
"""Deletes many images"""
try:
return ApiDependencies.invoker.services.images.delete_many(image_names)
except Exception as e:
raise HTTPException(status_code=500, detail="Failed to delete images")

View File

@@ -1,6 +1,5 @@
# Copyright (c) 2022-2023 Kyle Schouviller (https://github.com/kyle0654) and the InvokeAI Team
# Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654)
import asyncio
import sys
from inspect import signature
import uvicorn
@@ -21,13 +20,6 @@ from ..backend.util.logging import InvokeAILogger
app_config = InvokeAIAppConfig.get_config()
app_config.parse_args()
logger = InvokeAILogger.getLogger(config=app_config)
from invokeai.version.invokeai_version import __version__
# we call this early so that the message appears before
# other invokeai initialization messages
if app_config.version:
print(f'InvokeAI version {__version__}')
sys.exit(0)
import invokeai.frontend.web as web_dir
import mimetypes
@@ -36,7 +28,6 @@ from .api.dependencies import ApiDependencies
from .api.routers import sessions, models, images, boards, board_images, app_info
from .api.sockets import SocketIO
from .invocations.baseinvocation import BaseInvocation
import torch
if torch.backends.mps.is_available():

View File

@@ -16,12 +16,6 @@ from invokeai.backend.util.logging import InvokeAILogger
config = InvokeAIAppConfig.get_config()
config.parse_args()
logger = InvokeAILogger().getLogger(config=config)
from invokeai.version.invokeai_version import __version__
# we call this early so that the message appears before other invokeai initialization messages
if config.version:
print(f'InvokeAI version {__version__}')
sys.exit(0)
from invokeai.app.services.board_image_record_storage import (
SqliteBoardImageRecordStorage,
@@ -214,7 +208,6 @@ def invoke_all(context: CliContext):
raise SessionError()
def invoke_cli():
logger.info(f'InvokeAI version {__version__}')
# get the optional list of invocations to execute on the command line
parser = config.get_parser()
parser.add_argument('commands',nargs='*')

View File

@@ -1,6 +1,5 @@
from enum import Enum
from typing import Optional, Tuple
from pydantic import BaseModel, Field
from invokeai.app.util.metaenum import MetaEnum
@@ -89,41 +88,3 @@ class ProgressImage(BaseModel):
width: int = Field(description="The effective width of the image in pixels")
height: int = Field(description="The effective height of the image in pixels")
dataURL: str = Field(description="The image data as a b64 data URL")
class DeleteManyImagesResult(BaseModel):
"""The result of a delete many image operation."""
deleted_images: list[str] = Field(
description="The names of the images that were successfully deleted"
)
class AddManyImagesToBoardResult(BaseModel):
"""The result of an add many images to board operation."""
board_id: str = Field(description="The id of the board the images were added to")
added_images: list[str] = Field(
description="The names of the images that were successfully added"
)
total: int = Field(description="The total number of images on the board")
class RemoveManyImagesFromBoardResult(BaseModel):
"""The result of a remove many images from their boards operation."""
removed_images: list[str] = Field(
description="The names of the images that were successfully removed from their boards"
)
class GetAllBoardImagesForBoardResult(BaseModel):
"""The result of a get all image names for board operation."""
board_id: str = Field(
description="The id of the board with which the images are associated"
)
image_names: list[str] = Field(
description="The names of the images that are associated with the board"
)

View File

@@ -1,12 +1,13 @@
from abc import ABC, abstractmethod
import sqlite3
import threading
from abc import ABC, abstractmethod
from typing import Optional, cast
from invokeai.app.models.image import GetAllBoardImagesForBoardResult
from invokeai.app.services.image_record_storage import OffsetPaginatedResults
from invokeai.app.services.models.image_record import (
ImageRecord, deserialize_image_record)
ImageRecord,
deserialize_image_record,
)
class BoardImageRecordStorageBase(ABC):
@@ -24,17 +25,18 @@ class BoardImageRecordStorageBase(ABC):
@abstractmethod
def remove_image_from_board(
self,
board_id: str,
image_name: str,
) -> None:
"""Removes an image from a board."""
pass
@abstractmethod
def get_all_board_images_for_board(
def get_images_for_board(
self,
board_id: str,
) -> GetAllBoardImagesForBoardResult:
"""Gets all image names for a board."""
) -> OffsetPaginatedResults[ImageRecord]:
"""Gets images for a board."""
pass
@abstractmethod
@@ -152,6 +154,7 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
def remove_image_from_board(
self,
board_id: str,
image_name: str,
) -> None:
try:
@@ -159,9 +162,9 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
self._cursor.execute(
"""--sql
DELETE FROM board_images
WHERE image_name = ?;
WHERE board_id = ? AND image_name = ?;
""",
(image_name,),
(board_id, image_name),
)
self._conn.commit()
except sqlite3.Error as e:
@@ -170,32 +173,42 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
finally:
self._lock.release()
def get_all_board_images_for_board(
def get_images_for_board(
self,
board_id: str,
) -> GetAllBoardImagesForBoardResult:
offset: int = 0,
limit: int = 10,
) -> OffsetPaginatedResults[ImageRecord]:
# TODO: this isn't paginated yet?
try:
self._lock.acquire()
self._cursor.execute(
"""--sql
SELECT image_name
SELECT images.*
FROM board_images
WHERE board_id = ?
ORDER BY updated_at DESC;
INNER JOIN images ON board_images.image_name = images.image_name
WHERE board_images.board_id = ?
ORDER BY board_images.updated_at DESC;
""",
(board_id,),
)
result = cast(list[sqlite3.Row], self._cursor.fetchall())
image_names = list(map(lambda r: r[0], result))
images = list(map(lambda r: deserialize_image_record(dict(r)), result))
self._cursor.execute(
"""--sql
SELECT COUNT(*) FROM images WHERE 1=1;
"""
)
count = cast(int, self._cursor.fetchone()[0])
except sqlite3.Error as e:
self._conn.rollback()
raise e
finally:
self._lock.release()
return GetAllBoardImagesForBoardResult(
board_id=board_id, image_names=image_names
return OffsetPaginatedResults(
items=images, offset=offset, limit=limit, total=count
)
def get_board_for_image(

View File

@@ -1,19 +1,18 @@
from abc import ABC, abstractmethod
from logging import Logger
from typing import List, Optional, Union
from typing import List, Union, Optional
from invokeai.app.services.board_image_record_storage import BoardImageRecordStorageBase
from invokeai.app.services.board_record_storage import (
BoardRecord,
BoardRecordStorageBase,
)
from invokeai.app.models.image import (AddManyImagesToBoardResult,
GetAllBoardImagesForBoardResult,
RemoveManyImagesFromBoardResult)
from invokeai.app.services.board_image_record_storage import \
BoardImageRecordStorageBase
from invokeai.app.services.board_record_storage import (BoardRecord,
BoardRecordStorageBase)
from invokeai.app.services.image_record_storage import (ImageRecordStorageBase,
OffsetPaginatedResults)
from invokeai.app.services.image_record_storage import (
ImageRecordStorageBase,
OffsetPaginatedResults,
)
from invokeai.app.services.models.board_record import BoardDTO
from invokeai.app.services.models.image_record import (ImageDTO,
image_record_to_dto)
from invokeai.app.services.models.image_record import ImageDTO, image_record_to_dto
from invokeai.app.services.urls import UrlServiceBase
@@ -26,40 +25,24 @@ class BoardImagesServiceABC(ABC):
board_id: str,
image_name: str,
) -> None:
"""Adds an image to a board. If the image is on a different board, it is removed from that board."""
pass
@abstractmethod
def add_many_images_to_board(
self,
board_id: str,
image_names: list[str],
) -> AddManyImagesToBoardResult:
"""Adds many images to a board. If an image is on a different board, it is removed from that board."""
"""Adds an image to a board."""
pass
@abstractmethod
def remove_image_from_board(
self,
board_id: str,
image_name: str,
) -> None:
"""Removes an image from its board."""
"""Removes an image from a board."""
pass
@abstractmethod
def remove_many_images_from_board(
self,
image_names: list[str],
) -> RemoveManyImagesFromBoardResult:
"""Removes many images from their board, if they had one."""
pass
@abstractmethod
def get_all_board_images_for_board(
def get_images_for_board(
self,
board_id: str,
) -> GetAllBoardImagesForBoardResult:
"""Gets all image names for a board."""
) -> OffsetPaginatedResults[ImageDTO]:
"""Gets images for a board."""
pass
@abstractmethod
@@ -108,59 +91,37 @@ class BoardImagesService(BoardImagesServiceABC):
) -> None:
self._services.board_image_records.add_image_to_board(board_id, image_name)
def add_many_images_to_board(
self,
board_id: str,
image_names: list[str],
) -> AddManyImagesToBoardResult:
added_images: list[str] = []
for image_name in image_names:
try:
self._services.board_image_records.add_image_to_board(
board_id, image_name
)
added_images.append(image_name)
except Exception as e:
self._services.logger.exception(e)
total = self._services.board_image_records.get_image_count_for_board(board_id)
return AddManyImagesToBoardResult(
board_id=board_id, added_images=added_images, total=total
)
def remove_image_from_board(
self,
board_id: str,
image_name: str,
) -> None:
self._services.board_image_records.remove_image_from_board(image_name)
self._services.board_image_records.remove_image_from_board(board_id, image_name)
def remove_many_images_from_board(
self,
image_names: list[str],
) -> RemoveManyImagesFromBoardResult:
removed_images: list[str] = []
for image_name in image_names:
try:
self._services.board_image_records.remove_image_from_board(image_name)
removed_images.append(image_name)
except Exception as e:
self._services.logger.exception(e)
return RemoveManyImagesFromBoardResult(
removed_images=removed_images,
)
def get_all_board_images_for_board(
def get_images_for_board(
self,
board_id: str,
) -> GetAllBoardImagesForBoardResult:
result = self._services.board_image_records.get_all_board_images_for_board(
) -> OffsetPaginatedResults[ImageDTO]:
image_records = self._services.board_image_records.get_images_for_board(
board_id
)
return result
image_dtos = list(
map(
lambda r: image_record_to_dto(
r,
self._services.urls.get_image_url(r.image_name),
self._services.urls.get_image_url(r.image_name, True),
board_id,
),
image_records.items,
)
)
return OffsetPaginatedResults[ImageDTO](
items=image_dtos,
offset=image_records.offset,
limit=image_records.limit,
total=image_records.total,
)
def get_board_for_image(
self,
@@ -175,7 +136,7 @@ def board_record_to_dto(
) -> BoardDTO:
"""Converts a board record to a board DTO."""
return BoardDTO(
**board_record.dict(exclude={"cover_image_name"}),
**board_record.dict(exclude={'cover_image_name'}),
cover_image_name=cover_image_name,
image_count=image_count,
)

View File

@@ -23,8 +23,7 @@ InvokeAI:
xformers_enabled: false
sequential_guidance: false
precision: float16
max_cache_size: 6
max_vram_cache_size: 2.7
max_loaded_models: 4
always_use_cpu: false
free_gpu_mem: false
Features:
@@ -169,7 +168,7 @@ from argparse import ArgumentParser
from omegaconf import OmegaConf, DictConfig
from pathlib import Path
from pydantic import BaseSettings, Field, parse_obj_as
from typing import ClassVar, Dict, List, Set, Literal, Union, get_origin, get_type_hints, get_args
from typing import ClassVar, Dict, List, Literal, Union, get_origin, get_type_hints, get_args
INIT_FILE = Path('invokeai.yaml')
MODEL_CORE = Path('models/core')
@@ -271,8 +270,7 @@ class InvokeAISettings(BaseSettings):
@classmethod
def _excluded(self)->List[str]:
# combination of deprecated parameters and internal ones
return ['type','initconf', 'gpu_mem_reserved', 'max_loaded_models', 'version']
return ['type','initconf']
class Config:
env_file_encoding = 'utf-8'
@@ -365,10 +363,8 @@ setting environment variables INVOKEAI_<setting>.
always_use_cpu : bool = Field(default=False, description="If true, use the CPU for rendering even if a GPU is available.", category='Memory/Performance')
free_gpu_mem : bool = Field(default=False, description="If true, purge model from GPU after each generation.", category='Memory/Performance')
max_loaded_models : int = Field(default=3, gt=0, description="(DEPRECATED: use max_cache_size) Maximum number of models to keep in memory for rapid switching", category='DEPRECATED')
max_loaded_models : int = Field(default=3, gt=0, description="(DEPRECATED: use max_cache_size) Maximum number of models to keep in memory for rapid switching", category='Memory/Performance')
max_cache_size : float = Field(default=6.0, gt=0, description="Maximum memory amount used by model cache for rapid switching", category='Memory/Performance')
max_vram_cache_size : float = Field(default=2.75, ge=0, description="Amount of VRAM reserved for model storage", category='Memory/Performance')
gpu_mem_reserved : float = Field(default=2.75, ge=0, description="DEPRECATED: use max_vram_cache_size. Amount of VRAM reserved for model storage", category='DEPRECATED')
precision : Literal[tuple(['auto','float16','float32','autocast'])] = Field(default='float16',description='Floating point precision', category='Memory/Performance')
sequential_guidance : bool = Field(default=False, description="Whether to calculate guidance in serial instead of in parallel, lowering memory requirements", category='Memory/Performance')
xformers_enabled : bool = Field(default=True, description="Enable/disable memory-efficient attention", category='Memory/Performance')
@@ -393,8 +389,6 @@ setting environment variables INVOKEAI_<setting>.
# note - would be better to read the log_format values from logging.py, but this creates circular dependencies issues
log_format : Literal[tuple(['plain','color','syslog','legacy'])] = Field(default="color", description='Log format. Use "plain" for text-only, "color" for colorized output, "legacy" for 2.3-style logging and "syslog" for syslog-style', category="Logging")
log_level : Literal[tuple(["debug","info","warning","error","critical"])] = Field(default="debug", description="Emit logging messages at this level or higher", category="Logging")
version : bool = Field(default=False, description="Show InvokeAI version and exit", category="Other")
#fmt: on
def parse_args(self, argv: List[str]=None, conf: DictConfig = None, clobber=False):

View File

@@ -1,16 +1,22 @@
import sqlite3
import threading
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Generic, Optional, TypeVar, cast
import sqlite3
import threading
from pydantic import BaseModel, Field
from pydantic.generics import GenericModel
from invokeai.app.models.image import ImageCategory, ResourceOrigin
from invokeai.app.models.metadata import ImageMetadata
from invokeai.app.models.image import (
ImageCategory,
ResourceOrigin,
)
from invokeai.app.services.models.image_record import (
ImageRecord, ImageRecordChanges, deserialize_image_record)
ImageRecord,
ImageRecordChanges,
deserialize_image_record,
)
T = TypeVar("T", bound=BaseModel)
@@ -80,11 +86,6 @@ class ImageRecordStorageBase(ABC):
"""Gets a page of image records."""
pass
@abstractmethod
def get_by_names(self, image_names: list[str]) -> list[ImageRecord]:
"""Gets a list of image records by name."""
pass
# TODO: The database has a nullable `deleted_at` column, currently unused.
# Should we implement soft deletes? Would need coordination with ImageFileStorage.
@abstractmethod
@@ -161,6 +162,7 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
node_id TEXT,
metadata TEXT,
is_intermediate BOOLEAN DEFAULT FALSE,
board_id TEXT,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
@@ -334,15 +336,11 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
query_params.append(is_intermediate)
if board_id is not None:
if board_id == "none":
query_conditions += """--sql
AND board_images.board_id IS NULL
"""
else:
query_conditions += """--sql
AND board_images.board_id = ?
"""
query_params.append(board_id)
query_conditions += """--sql
AND board_images.board_id = ?
"""
query_params.append(board_id)
query_pagination = """--sql
ORDER BY images.created_at DESC LIMIT ? OFFSET ?
@@ -374,30 +372,6 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
items=images, offset=offset, limit=limit, total=count
)
def get_by_names(self, image_names: list[str]) -> list[ImageRecord]:
try:
placeholders = ",".join("?" for _ in image_names)
self._lock.acquire()
# Construct the SQLite query with the placeholders
query = f"""--sql
SELECT * FROM images
WHERE image_name IN ({placeholders})
"""
# Execute the query with the list of IDs as parameters
self._cursor.execute(query, image_names)
result = cast(list[sqlite3.Row], self._cursor.fetchall())
images = list(map(lambda r: deserialize_image_record(dict(r)), result))
return images
except sqlite3.Error as e:
self._conn.rollback()
raise e
finally:
self._lock.release()
def delete(self, image_name: str) -> None:
try:
self._lock.acquire()
@@ -498,7 +472,9 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
finally:
self._lock.release()
def get_most_recent_image_for_board(self, board_id: str) -> Optional[ImageRecord]:
def get_most_recent_image_for_board(
self, board_id: str
) -> Optional[ImageRecord]:
try:
self._lock.acquire()
self._cursor.execute(

View File

@@ -1,27 +1,37 @@
from abc import ABC, abstractmethod
from logging import Logger
from typing import TYPE_CHECKING, Optional
from typing import Optional, TYPE_CHECKING, Union
from PIL.Image import Image as PILImageType
from invokeai.app.models.image import (DeleteManyImagesResult, ImageCategory,
InvalidImageCategoryException,
InvalidOriginException, ResourceOrigin)
from invokeai.app.models.image import (
ImageCategory,
ResourceOrigin,
InvalidImageCategoryException,
InvalidOriginException,
)
from invokeai.app.models.metadata import ImageMetadata
from invokeai.app.services.board_image_record_storage import \
BoardImageRecordStorageBase
from invokeai.app.services.image_file_storage import (
ImageFileDeleteException, ImageFileNotFoundException,
ImageFileSaveException, ImageFileStorageBase)
from invokeai.app.services.board_image_record_storage import BoardImageRecordStorageBase
from invokeai.app.services.image_record_storage import (
ImageRecordDeleteException, ImageRecordNotFoundException,
ImageRecordSaveException, ImageRecordStorageBase, OffsetPaginatedResults)
from invokeai.app.services.item_storage import ItemStorageABC
ImageRecordDeleteException,
ImageRecordNotFoundException,
ImageRecordSaveException,
ImageRecordStorageBase,
OffsetPaginatedResults,
)
from invokeai.app.services.models.image_record import (
ImageRecord,
ImageDTO,
ImageRecordChanges,
image_record_to_dto,
)
from invokeai.app.services.image_file_storage import (
ImageFileDeleteException,
ImageFileNotFoundException,
ImageFileSaveException,
ImageFileStorageBase,
)
from invokeai.app.services.item_storage import ItemStorageABC, PaginatedResults
from invokeai.app.services.metadata import MetadataServiceBase
from invokeai.app.services.models.image_record import (GetImagesByNamesResult,
ImageDTO, ImageRecord,
ImageRecordChanges,
image_record_to_dto)
from invokeai.app.services.resource_name import NameServiceBase
from invokeai.app.services.urls import UrlServiceBase
@@ -97,23 +107,13 @@ class ImageServiceABC(ABC):
"""Gets a paginated list of image DTOs."""
pass
@abstractmethod
def get_images_by_names(self, image_names: list[str]) -> GetImagesByNamesResult:
"""Gets image DTOs by list of names."""
pass
@abstractmethod
def delete(self, image_name: str):
"""Deletes an image."""
pass
@abstractmethod
def delete_many(self, image_names: list[str]) -> DeleteManyImagesResult:
"""Deletes many images."""
pass
@abstractmethod
def delete_images_on_board(self, board_id: str) -> DeleteManyImagesResult:
def delete_images_on_board(self, board_id: str):
"""Deletes all images on a board."""
pass
@@ -332,28 +332,6 @@ class ImageService(ImageServiceABC):
self._services.logger.error("Problem getting paginated image DTOs")
raise e
def get_images_by_names(self, image_names: list[str]) -> GetImagesByNamesResult:
try:
image_records = self._services.image_records.get_by_names(image_names)
image_dtos = list(
map(
lambda r: image_record_to_dto(
r,
self._services.urls.get_image_url(r.image_name),
self._services.urls.get_image_url(r.image_name, True),
self._services.board_image_records.get_board_for_image(
r.image_name
),
),
image_records,
)
)
return GetImagesByNamesResult(image_dtos=image_dtos)
except Exception as e:
self._services.logger.error("Problem getting image DTOs from names")
raise e
def delete(self, image_name: str):
try:
self._services.image_files.delete(image_name)
@@ -368,36 +346,18 @@ class ImageService(ImageServiceABC):
self._services.logger.error("Problem deleting image record and file")
raise e
def delete_many(self, image_names: list[str]) -> DeleteManyImagesResult:
deleted_images: list[str] = []
for image_name in image_names:
try:
self._services.image_files.delete(image_name)
self._services.image_records.delete(image_name)
deleted_images.append(image_name)
except ImageRecordDeleteException:
self._services.logger.error(f"Failed to delete image record")
deleted_images.append(image_name)
except ImageFileDeleteException:
self._services.logger.error(f"Failed to delete image file")
deleted_images.append(image_name)
except Exception as e:
self._services.logger.error("Problem deleting image record and file")
deleted_images.append(image_name)
return DeleteManyImagesResult(deleted_images=deleted_images)
def delete_images_on_board(self, board_id: str) -> DeleteManyImagesResult:
def delete_images_on_board(self, board_id: str):
try:
board_images = (
self._services.board_image_records.get_all_board_images_for_board(
board_id
images = self._services.board_image_records.get_images_for_board(board_id)
image_name_list = list(
map(
lambda r: r.image_name,
images.items,
)
)
image_name_list = board_images.image_names
for image_name in image_name_list:
self._services.image_files.delete(image_name)
self._services.image_records.delete_many(image_name_list)
return DeleteManyImagesResult(deleted_images=board_images.image_names)
except ImageRecordDeleteException:
self._services.logger.error(f"Failed to delete image records")
raise

View File

@@ -258,7 +258,9 @@ class ModelManagerService(ModelManagerServiceBase):
config_file = config.model_conf_path
else:
config_file = config.root_dir / "configs/models.yaml"
if not config_file.exists():
raise IOError(f"The file {config_file} could not be found.")
logger.debug(f'config file={config_file}')
device = torch.device(choose_torch_device())

View File

@@ -1,8 +1,6 @@
import datetime
from typing import Optional, Union
from pydantic import BaseModel, Extra, Field, StrictBool, StrictStr
from invokeai.app.models.image import ImageCategory, ResourceOrigin
from invokeai.app.models.metadata import ImageMetadata
from invokeai.app.util.misc import get_iso_timestamp
@@ -97,19 +95,8 @@ class ImageDTO(ImageRecord, ImageUrlsDTO):
pass
class GetImagesByNamesResult(BaseModel):
"""The result of a get all image names for board operation."""
image_dtos: list[ImageDTO] = Field(
description="The names of the images that are associated with the board"
)
def image_record_to_dto(
image_record: ImageRecord,
image_url: str,
thumbnail_url: str,
board_id: Optional[str],
image_record: ImageRecord, image_url: str, thumbnail_url: str, board_id: Optional[str]
) -> ImageDTO:
"""Converts an image record to an image DTO."""
return ImageDTO(

View File

@@ -104,7 +104,6 @@ class DefaultInvocationProcessor(InvocationProcessorABC):
except Exception as e:
error = traceback.format_exc()
logger.error(error)
# Save error
graph_execution_state.set_node_error(invocation.id, error)

View File

@@ -22,4 +22,4 @@ class LocalUrlService(UrlServiceBase):
if thumbnail:
return f"{self._base_url}/images/{image_basename}/thumbnail"
return f"{self._base_url}/images/{image_basename}/full_size"
return f"{self._base_url}/images/{image_basename}"

View File

@@ -36,9 +36,6 @@ from .models import BaseModelType, ModelType, SubModelType, ModelBase
# Default is roughly enough to hold three fp16 diffusers models in RAM simultaneously
DEFAULT_MAX_CACHE_SIZE = 6.0
# amount of GPU memory to hold in reserve for use by generations (GB)
DEFAULT_MAX_VRAM_CACHE_SIZE= 2.75
# actual size of a gig
GIG = 1073741824
@@ -85,7 +82,6 @@ class ModelCache(object):
def __init__(
self,
max_cache_size: float=DEFAULT_MAX_CACHE_SIZE,
max_vram_cache_size: float=DEFAULT_MAX_VRAM_CACHE_SIZE,
execution_device: torch.device=torch.device('cuda'),
storage_device: torch.device=torch.device('cpu'),
precision: torch.dtype=torch.float16,
@@ -103,11 +99,12 @@ class ModelCache(object):
:param sequential_offload: Conserve VRAM by loading and unloading each stage of the pipeline sequentially
:param sha_chunksize: Chunksize to use when calculating sha256 model hash
'''
#max_cache_size = 9999
self.model_infos: Dict[str, ModelBase] = dict()
self.lazy_offloading = lazy_offloading
#self.sequential_offload: bool=sequential_offload
self.precision: torch.dtype=precision
self.max_cache_size: float=max_cache_size
self.max_vram_cache_size: float=max_vram_cache_size
self.max_cache_size: int=max_cache_size
self.execution_device: torch.device=execution_device
self.storage_device: torch.device=storage_device
self.sha_chunksize=sha_chunksize
@@ -204,22 +201,14 @@ class ModelCache(object):
self._cache_stack.remove(key)
self._cache_stack.append(key)
return self.ModelLocker(self, key, cache_entry.model, gpu_load, cache_entry.size)
return self.ModelLocker(self, key, cache_entry.model, gpu_load)
class ModelLocker(object):
def __init__(self, cache, key, model, gpu_load, size_needed):
'''
:param cache: The model_cache object
:param key: The key of the model to lock in GPU
:param model: The model to lock
:param gpu_load: True if load into gpu
:param size_needed: Size of the model to load
'''
def __init__(self, cache, key, model, gpu_load):
self.gpu_load = gpu_load
self.cache = cache
self.key = key
self.model = model
self.size_needed = size_needed
self.cache_entry = self.cache._cached_models[self.key]
def __enter__(self) -> Any:
@@ -233,7 +222,7 @@ class ModelCache(object):
try:
if self.cache.lazy_offloading:
self.cache._offload_unlocked_models(self.size_needed)
self.cache._offload_unlocked_models()
if self.model.device != self.cache.execution_device:
self.cache.logger.debug(f'Moving {self.key} into {self.cache.execution_device}')
@@ -348,20 +337,14 @@ class ModelCache(object):
self.logger.debug(f"After unloading: cached_models={len(self._cached_models)}")
def _offload_unlocked_models(self, size_needed: int=0):
reserved = self.max_vram_cache_size * GIG
vram_in_use = torch.cuda.memory_allocated()
self.logger.debug(f'{(vram_in_use/GIG):.2f}GB VRAM used for models; max allowed={(reserved/GIG):.2f}GB')
for model_key, cache_entry in sorted(self._cached_models.items(), key=lambda x:x[1].size):
if vram_in_use <= reserved:
break
def _offload_unlocked_models(self):
for model_key, cache_entry in self._cached_models.items():
if not cache_entry.locked and cache_entry.loaded:
self.logger.debug(f'Offloading {model_key} from {self.execution_device} into {self.storage_device}')
with VRAMUsage() as mem:
cache_entry.model.to(self.storage_device)
self.logger.debug(f'GPU VRAM freed: {(mem.vram_used/GIG):.2f} GB')
vram_in_use += mem.vram_used # note vram_used is negative
self.logger.debug(f'{(vram_in_use/GIG):.2f}GB VRAM used for models; max allowed={(reserved/GIG):.2f}GB')
def _local_model_hash(self, model_path: Union[str, Path]) -> str:
sha = hashlib.sha256()

View File

@@ -231,7 +231,6 @@ from __future__ import annotations
import os
import hashlib
import textwrap
import yaml
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, List, Tuple, Union, Dict, Set, Callable, types
@@ -250,8 +249,8 @@ from .model_cache import ModelCache, ModelLocker
from .models import (
BaseModelType, ModelType, SubModelType,
ModelError, SchedulerPredictionType, MODEL_CLASSES,
ModelConfigBase, ModelNotFoundException, InvalidModelException,
)
ModelConfigBase, ModelNotFoundException,
)
# We are only starting to number the config file with release 3.
# The config file version doesn't have to start at release version, but it will help
@@ -275,6 +274,10 @@ class ModelInfo():
def __exit__(self,*args, **kwargs):
self.context.__exit__(*args, **kwargs)
class InvalidModelError(Exception):
"Raised when an invalid model is requested"
pass
class AddModelResult(BaseModel):
name: str = Field(description="The name of the model after installation")
model_type: ModelType = Field(description="The type of model")
@@ -311,9 +314,6 @@ class ModelManager(object):
self.config_path = None
if isinstance(config, (str, Path)):
self.config_path = Path(config)
if not self.config_path.exists():
logger.warning(f'The file {self.config_path} was not found. Initializing a new file')
self.initialize_model_config(self.config_path)
config = OmegaConf.load(self.config_path)
elif not isinstance(config, DictConfig):
@@ -336,7 +336,6 @@ class ModelManager(object):
self.logger = logger
self.cache = ModelCache(
max_cache_size=max_cache_size,
max_vram_cache_size = self.app_config.max_vram_cache_size,
execution_device = device_type,
precision = precision,
sequential_offload = sequential_offload,
@@ -387,16 +386,6 @@ class ModelManager(object):
def _get_model_cache_path(self, model_path):
return self.app_config.models_path / ".cache" / hashlib.md5(str(model_path).encode()).hexdigest()
@classmethod
def initialize_model_config(cls, config_path: Path):
"""Create empty config file"""
with open(config_path,'w') as yaml_file:
yaml_file.write(yaml.dump({'__metadata__':
{'version':'3.0.0'}
}
)
)
def get_model(
self,
model_name: str,
@@ -813,8 +802,6 @@ class ModelManager(object):
model_config: ModelConfigBase = model_class.probe_config(str(model_path))
self.models[model_key] = model_config
new_models_found = True
except InvalidModelException:
self.logger.warning(f"Not a valid model: {model_path}")
except NotImplementedError as e:
self.logger.warning(e)
@@ -866,22 +853,16 @@ class ModelManager(object):
scanned_dirs.add(path)
continue
if any([(path/x).exists() for x in {'config.json','model_index.json','learned_embeds.bin','pytorch_lora_weights.bin'}]):
try:
new_models_found.update(installer.heuristic_import(path))
scanned_dirs.add(path)
except ValueError as e:
self.logger.warning(str(e))
new_models_found.update(installer.heuristic_import(path))
scanned_dirs.add(path)
for f in files:
path = Path(root) / f
if path in known_paths or path.parent in scanned_dirs:
continue
if path.suffix in {'.ckpt','.bin','.pth','.safetensors','.pt'}:
try:
import_result = installer.heuristic_import(path)
new_models_found.update(import_result)
except ValueError as e:
self.logger.warning(str(e))
import_result = installer.heuristic_import(path)
new_models_found.update(import_result)
self.logger.info(f'Scanned {items_scanned} files and directories, imported {len(new_models_found)} models')
installed.update(new_models_found)

View File

@@ -59,7 +59,7 @@ class ModelProbe(object):
elif isinstance(model,(dict,ModelMixin,ConfigMixin)):
return cls.probe(model_path=None, model=model, prediction_type_helper=prediction_type_helper)
else:
raise ValueError("model parameter {model} is neither a Path, nor a model")
raise Exception("model parameter {model} is neither a Path, nor a model")
@classmethod
def probe(cls,
@@ -237,7 +237,7 @@ class CheckpointProbeBase(ProbeBase):
elif in_channels == 4:
return ModelVariantType.Normal
else:
raise ValueError(f"Cannot determine variant type (in_channels={in_channels}) at {self.checkpoint_path}")
raise Exception("Cannot determine variant type")
class PipelineCheckpointProbe(CheckpointProbeBase):
def get_base_type(self)->BaseModelType:
@@ -248,7 +248,7 @@ class PipelineCheckpointProbe(CheckpointProbeBase):
return BaseModelType.StableDiffusion1
if key_name in state_dict and state_dict[key_name].shape[-1] == 1024:
return BaseModelType.StableDiffusion2
raise ValueError("Cannot determine base type")
raise Exception("Cannot determine base type")
def get_scheduler_prediction_type(self)->SchedulerPredictionType:
type = self.get_base_type()
@@ -329,7 +329,7 @@ class ControlNetCheckpointProbe(CheckpointProbeBase):
return BaseModelType.StableDiffusion2
elif self.checkpoint_path and self.helper:
return self.helper(self.checkpoint_path)
raise ValueError("Unable to determine base type for {self.checkpoint_path}")
raise Exception("Unable to determine base type for {self.checkpoint_path}")
########################################################
# classes for probing folders
@@ -418,7 +418,7 @@ class ControlNetFolderProbe(FolderProbeBase):
def get_base_type(self)->BaseModelType:
config_file = self.folder_path / 'config.json'
if not config_file.exists():
raise ValueError(f"Cannot determine base type for {self.folder_path}")
raise Exception(f"Cannot determine base type for {self.folder_path}")
with open(config_file,'r') as file:
config = json.load(file)
# no obvious way to distinguish between sd2-base and sd2-768
@@ -435,7 +435,7 @@ class LoRAFolderProbe(FolderProbeBase):
model_file = base_file
break
if not model_file:
raise ValueError('Unknown LoRA format encountered')
raise Exception('Unknown LoRA format encountered')
return LoRACheckpointProbe(model_file,None).get_base_type()
############## register probe classes ######

View File

@@ -2,7 +2,7 @@ import inspect
from enum import Enum
from pydantic import BaseModel
from typing import Literal, get_origin
from .base import BaseModelType, ModelType, SubModelType, ModelBase, ModelConfigBase, ModelVariantType, SchedulerPredictionType, ModelError, SilenceWarnings, ModelNotFoundException, InvalidModelException
from .base import BaseModelType, ModelType, SubModelType, ModelBase, ModelConfigBase, ModelVariantType, SchedulerPredictionType, ModelError, SilenceWarnings, ModelNotFoundException
from .stable_diffusion import StableDiffusion1Model, StableDiffusion2Model
from .vae import VaeModel
from .lora import LoRAModel

View File

@@ -15,9 +15,6 @@ from contextlib import suppress
from pydantic import BaseModel, Field
from typing import List, Dict, Optional, Type, Literal, TypeVar, Generic, Callable, Any, Union
class InvalidModelException(Exception):
pass
class ModelNotFoundException(Exception):
pass

View File

@@ -13,7 +13,6 @@ from .base import (
calc_model_size_by_fs,
calc_model_size_by_data,
classproperty,
InvalidModelException,
)
class ControlNetModelFormat(str, Enum):
@@ -74,18 +73,10 @@ class ControlNetModel(ModelBase):
@classmethod
def detect_format(cls, path: str):
if not os.path.exists(path):
raise ModelNotFoundException()
if os.path.isdir(path):
if os.path.exists(os.path.join(path, "config.json")):
return ControlNetModelFormat.Diffusers
if os.path.isfile(path):
if any([path.endswith(f".{ext}") for ext in ["safetensors", "ckpt", "pt", "pth"]]):
return ControlNetModelFormat.Checkpoint
raise InvalidModelException(f"Not a valid model: {path}")
return ControlNetModelFormat.Diffusers
else:
return ControlNetModelFormat.Checkpoint
@classmethod
def convert_if_required(

View File

@@ -9,7 +9,6 @@ from .base import (
ModelType,
SubModelType,
classproperty,
InvalidModelException,
)
# TODO: naming
from ..lora import LoRAModel as LoRAModelRaw
@@ -57,18 +56,10 @@ class LoRAModel(ModelBase):
@classmethod
def detect_format(cls, path: str):
if not os.path.exists(path):
raise ModelNotFoundException()
if os.path.isdir(path):
if os.path.exists(os.path.join(path, "pytorch_lora_weights.bin")):
return LoRAModelFormat.Diffusers
if os.path.isfile(path):
if any([path.endswith(f".{ext}") for ext in ["safetensors", "ckpt", "pt"]]):
return LoRAModelFormat.LyCORIS
raise InvalidModelException(f"Not a valid model: {path}")
return LoRAModelFormat.Diffusers
else:
return LoRAModelFormat.LyCORIS
@classmethod
def convert_if_required(

View File

@@ -16,7 +16,6 @@ from .base import (
SilenceWarnings,
read_checkpoint_meta,
classproperty,
InvalidModelException,
)
from invokeai.app.services.config import InvokeAIAppConfig
from omegaconf import OmegaConf
@@ -99,18 +98,10 @@ class StableDiffusion1Model(DiffusersModel):
@classmethod
def detect_format(cls, model_path: str):
if not os.path.exists(model_path):
raise ModelNotFoundException()
if os.path.isdir(model_path):
if os.path.exists(os.path.join(model_path, "model_index.json")):
return StableDiffusion1ModelFormat.Diffusers
if os.path.isfile(model_path):
if any([model_path.endswith(f".{ext}") for ext in ["safetensors", "ckpt", "pt"]]):
return StableDiffusion1ModelFormat.Checkpoint
raise InvalidModelException(f"Not a valid model: {model_path}")
return StableDiffusion1ModelFormat.Diffusers
else:
return StableDiffusion1ModelFormat.Checkpoint
@classmethod
def convert_if_required(
@@ -209,18 +200,10 @@ class StableDiffusion2Model(DiffusersModel):
@classmethod
def detect_format(cls, model_path: str):
if not os.path.exists(model_path):
raise ModelNotFoundException()
if os.path.isdir(model_path):
if os.path.exists(os.path.join(model_path, "model_index.json")):
return StableDiffusion2ModelFormat.Diffusers
if os.path.isfile(model_path):
if any([model_path.endswith(f".{ext}") for ext in ["safetensors", "ckpt", "pt"]]):
return StableDiffusion2ModelFormat.Checkpoint
raise InvalidModelException(f"Not a valid model: {model_path}")
return StableDiffusion2ModelFormat.Diffusers
else:
return StableDiffusion2ModelFormat.Checkpoint
@classmethod
def convert_if_required(

View File

@@ -9,7 +9,6 @@ from .base import (
SubModelType,
classproperty,
ModelNotFoundException,
InvalidModelException,
)
# TODO: naming
from ..lora import TextualInversionModel as TextualInversionModelRaw
@@ -60,18 +59,7 @@ class TextualInversionModel(ModelBase):
@classmethod
def detect_format(cls, path: str):
if not os.path.exists(path):
raise ModelNotFoundException()
if os.path.isdir(path):
if os.path.exists(os.path.join(path, "learned_embeds.bin")):
return None # diffusers-ti
if os.path.isfile(path):
if any([path.endswith(f".{ext}") for ext in ["safetensors", "ckpt", "pt"]]):
return None
raise InvalidModelException(f"Not a valid model: {path}")
return None
@classmethod
def convert_if_required(

View File

@@ -15,7 +15,6 @@ from .base import (
calc_model_size_by_fs,
calc_model_size_by_data,
classproperty,
InvalidModelException,
)
from invokeai.app.services.config import InvokeAIAppConfig
from diffusers.utils import is_safetensors_available
@@ -76,18 +75,10 @@ class VaeModel(ModelBase):
@classmethod
def detect_format(cls, path: str):
if not os.path.exists(path):
raise ModelNotFoundException()
if os.path.isdir(path):
if os.path.exists(os.path.join(path, "config.json")):
return VaeModelFormat.Diffusers
if os.path.isfile(path):
if any([path.endswith(f".{ext}") for ext in ["safetensors", "ckpt", "pt"]]):
return VaeModelFormat.Checkpoint
raise InvalidModelException(f"Not a valid model: {path}")
return VaeModelFormat.Diffusers
else:
return VaeModelFormat.Checkpoint
@classmethod
def convert_if_required(

View File

@@ -773,7 +773,7 @@ def main():
config.parse_args(invoke_args)
logger = InvokeAILogger().getLogger(config=config)
if not (config.conf_path / 'models.yaml').exists():
if not (config.root_dir / config.conf_path.parent).exists():
logger.info(
"Your InvokeAI root directory is not set up. Calling invokeai-configure."
)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -12,7 +12,7 @@
margin: 0;
}
</style>
<script type="module" crossorigin src="./assets/index-078526aa.js"></script>
<script type="module" crossorigin src="./assets/index-581af3d4.js"></script>
</head>
<body dir="ltr">

View File

@@ -53,7 +53,7 @@
"linear": "Linear",
"nodes": "Node Editor",
"batch": "Batch Manager",
"modelManager": "Model Manager",
"modelmanager": "Model Manager",
"postprocessing": "Post Processing",
"nodesDesc": "A node based system for the generation of images is under development currently. Stay tuned for updates about this amazing feature.",
"postProcessing": "Post Processing",
@@ -527,9 +527,7 @@
"showOptionsPanel": "Show Options Panel",
"hidePreview": "Hide Preview",
"showPreview": "Show Preview",
"controlNetControlMode": "Control Mode",
"clipSkip": "Clip Skip",
"aspectRatio": "Ratio"
"controlNetControlMode": "Control Mode"
},
"settings": {
"models": "Models",
@@ -553,8 +551,7 @@
"generation": "Generation",
"ui": "User Interface",
"favoriteSchedulers": "Favorite Schedulers",
"favoriteSchedulersPlaceholder": "No schedulers favorited",
"showAdvancedOptions": "Show Advanced Options"
"favoriteSchedulersPlaceholder": "No schedulers favorited"
},
"toast": {
"serverError": "Server Error",
@@ -672,7 +669,6 @@
},
"ui": {
"showProgressImages": "Show Progress Images",
"hideProgressImages": "Hide Progress Images",
"swapSizes": "Swap Sizes"
"hideProgressImages": "Hide Progress Images"
}
}

View File

@@ -128,13 +128,13 @@
"@types/react-redux": "^7.1.25",
"@types/react-transition-group": "^4.4.6",
"@types/uuid": "^9.0.2",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^5.60.0",
"@typescript-eslint/parser": "^5.60.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"axios": "^1.4.0",
"babel-plugin-transform-imports": "^2.0.0",
"concurrently": "^8.2.0",
"eslint": "^8.44.0",
"eslint": "^8.43.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.32.2",
@@ -151,8 +151,6 @@
"rollup-plugin-visualizer": "^5.9.2",
"terser": "^5.18.1",
"ts-toolbelt": "^9.6.0",
"typescript": "^5.1.6",
"typescript-eslint": "^0.0.1-alpha.0",
"vite": "^4.3.9",
"vite-plugin-css-injected-by-js": "^3.1.1",
"vite-plugin-dts": "^2.3.0",

View File

@@ -53,7 +53,7 @@
"linear": "Linear",
"nodes": "Node Editor",
"batch": "Batch Manager",
"modelManager": "Model Manager",
"modelmanager": "Model Manager",
"postprocessing": "Post Processing",
"nodesDesc": "A node based system for the generation of images is under development currently. Stay tuned for updates about this amazing feature.",
"postProcessing": "Post Processing",
@@ -118,7 +118,7 @@
"pinGallery": "Pin Gallery",
"allImagesLoaded": "All Images Loaded",
"loadMore": "Load More",
"noImagesInGallery": "No Images to Display",
"noImagesInGallery": "No Images In Gallery",
"deleteImage": "Delete Image",
"deleteImageBin": "Deleted images will be sent to your operating system's Bin.",
"deleteImagePermanent": "Deleted images cannot be restored.",
@@ -528,8 +528,7 @@
"hidePreview": "Hide Preview",
"showPreview": "Show Preview",
"controlNetControlMode": "Control Mode",
"clipSkip": "CLIP Skip",
"aspectRatio": "Ratio"
"clipSkip": "Clip Skip"
},
"settings": {
"models": "Models",
@@ -593,10 +592,7 @@
"metadataLoadFailed": "Failed to load metadata",
"initialImageSet": "Initial Image Set",
"initialImageNotSet": "Initial Image Not Set",
"initialImageNotSetDesc": "Could not load initial image",
"nodesSaved": "Nodes Saved",
"nodesLoaded": "Nodes Loaded",
"nodesLoadedFailed": "Failed To Load Nodes"
"initialImageNotSetDesc": "Could not load initial image"
},
"tooltip": {
"feature": {
@@ -675,12 +671,6 @@
},
"ui": {
"showProgressImages": "Show Progress Images",
"hideProgressImages": "Hide Progress Images",
"swapSizes": "Swap Sizes"
},
"nodes": {
"reloadSchema": "Reload Schema",
"saveNodes": "Save Nodes",
"loadNodes": "Load Nodes"
"hideProgressImages": "Hide Progress Images"
}
}

View File

@@ -82,7 +82,7 @@ const DragPreview = (props: OverlayDragImageProps) => {
);
}
if (props.dragData.payloadType === 'IMAGE_NAMES') {
if (props.dragData.payloadType === 'BATCH_SELECTION') {
return (
<Flex
sx={{
@@ -95,7 +95,26 @@ const DragPreview = (props: OverlayDragImageProps) => {
...STYLES,
}}
>
<Heading>{props.dragData.payload.image_names.length}</Heading>
<Heading>{batchSelectionCount}</Heading>
<Heading size="sm">Images</Heading>
</Flex>
);
}
if (props.dragData.payloadType === 'GALLERY_SELECTION') {
return (
<Flex
sx={{
cursor: 'none',
userSelect: 'none',
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
flexDir: 'column',
...STYLES,
}}
>
<Heading>{gallerySelectionCount}</Heading>
<Heading size="sm">Images</Heading>
</Flex>
);

View File

@@ -6,18 +6,18 @@ import {
useSensor,
useSensors,
} from '@dnd-kit/core';
import { snapCenterToCursor } from '@dnd-kit/modifiers';
import { dndDropped } from 'app/store/middleware/listenerMiddleware/listeners/imageDropped';
import { useAppDispatch } from 'app/store/storeHooks';
import { AnimatePresence, motion } from 'framer-motion';
import { PropsWithChildren, memo, useCallback, useState } from 'react';
import DragPreview from './DragPreview';
import { snapCenterToCursor } from '@dnd-kit/modifiers';
import { AnimatePresence, motion } from 'framer-motion';
import {
DndContext,
DragEndEvent,
DragStartEvent,
TypesafeDraggableData,
} from './typesafeDnd';
import { useAppDispatch } from 'app/store/storeHooks';
import { imageDropped } from 'app/store/middleware/listenerMiddleware/listeners/imageDropped';
type ImageDndContextProps = PropsWithChildren;
@@ -42,18 +42,18 @@ const ImageDndContext = (props: ImageDndContextProps) => {
if (!activeData || !overData) {
return;
}
dispatch(dndDropped({ overData, activeData }));
dispatch(imageDropped({ overData, activeData }));
setActiveDragData(null);
},
[dispatch]
);
const mouseSensor = useSensor(MouseSensor, {
activationConstraint: { distance: 10 },
activationConstraint: { delay: 150, tolerance: 5 },
});
const touchSensor = useSensor(TouchSensor, {
activationConstraint: { distance: 10 },
activationConstraint: { delay: 150, tolerance: 5 },
});
// TODO: Use KeyboardSensor - needs composition of multiple collisionDetection algos

View File

@@ -77,14 +77,18 @@ export type ImageDraggableData = BaseDragData & {
payload: { imageDTO: ImageDTO };
};
export type ImageNamesDraggableData = BaseDragData & {
payloadType: 'IMAGE_NAMES';
payload: { image_names: string[] };
export type GallerySelectionDraggableData = BaseDragData & {
payloadType: 'GALLERY_SELECTION';
};
export type BatchSelectionDraggableData = BaseDragData & {
payloadType: 'BATCH_SELECTION';
};
export type TypesafeDraggableData =
| ImageDraggableData
| ImageNamesDraggableData;
| GallerySelectionDraggableData
| BatchSelectionDraggableData;
interface UseDroppableTypesafeArguments
extends Omit<UseDroppableArguments, 'data'> {
@@ -155,11 +159,13 @@ export const isValidDrop = (
case 'SET_NODES_IMAGE':
return payloadType === 'IMAGE_DTO';
case 'SET_MULTI_NODES_IMAGE':
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
return payloadType === 'IMAGE_DTO' || 'GALLERY_SELECTION';
case 'ADD_TO_BATCH':
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
return payloadType === 'IMAGE_DTO' || 'GALLERY_SELECTION';
case 'MOVE_BOARD':
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
return (
payloadType === 'IMAGE_DTO' || 'GALLERY_SELECTION' || 'BATCH_SELECTION'
);
default:
return false;
}

View File

@@ -1,7 +1,7 @@
import { useDisclosure } from '@chakra-ui/react';
import { PropsWithChildren, createContext, useCallback, useState } from 'react';
import { useAddBoardImageMutation } from 'services/api/endpoints/boardImages';
import { ImageDTO } from 'services/api/types';
import { useAddImageToBoardMutation } from 'services/api/endpoints/boardImages';
export type ImageUsage = {
isInitialImage: boolean;
@@ -41,7 +41,7 @@ export const AddImageToBoardContextProvider = (props: Props) => {
const [imageToMove, setImageToMove] = useState<ImageDTO>();
const { isOpen, onOpen, onClose } = useDisclosure();
const [addImageToBoard, result] = useAddBoardImageMutation();
const [addImageToBoard, result] = useAddImageToBoardMutation();
// Clean up after deleting or dismissing the modal
const closeAndClearImageToDelete = useCallback(() => {

View File

@@ -1,4 +1,3 @@
import { initialBatchState } from 'features/batch/store/batchSlice';
import { initialCanvasState } from 'features/canvas/store/canvasSlice';
import { initialControlNetState } from 'features/controlNet/store/controlNetSlice';
import { initialGalleryState } from 'features/gallery/store/gallerySlice';
@@ -18,7 +17,6 @@ const initialStates: {
} = {
canvas: initialCanvasState,
gallery: initialGalleryState,
batch: initialBatchState,
generation: initialGenerationState,
lightbox: initialLightboxState,
nodes: initialNodesState,

View File

@@ -1,8 +1,4 @@
/**
* This is a list of actions that should be excluded in the Redux DevTools.
*/
export const actionsDenylist = [
// very spammy canvas actions
'canvas/setCursorPosition',
'canvas/setStageCoordinates',
'canvas/setStageScale',
@@ -11,11 +7,7 @@ export const actionsDenylist = [
'canvas/setBoundingBoxDimensions',
'canvas/setIsDrawing',
'canvas/addPointToCurrentLine',
// bazillions during generation
'socket/socketGeneratorProgress',
'socket/appSocketGeneratorProgress',
// every time user presses shift
'hotkeys/shiftKeyPressed',
// this happens after every state change
'@@REMEMBER_PERSISTED',
];

View File

@@ -7,8 +7,6 @@ import {
} from '@reduxjs/toolkit';
import type { AppDispatch, RootState } from '../../store';
import { addBoardApiListeners } from './listeners/addBoardApiListeners';
import { addAddBoardToBatchListener } from './listeners/addBoardToBatch';
import { addCommitStagingAreaImageListener } from './listeners/addCommitStagingAreaImageListener';
import { addAppStartedListener } from './listeners/appStarted';
import { addBoardIdSelectedListener } from './listeners/boardIdSelected';
@@ -20,9 +18,9 @@ import { addCanvasSavedToGalleryListener } from './listeners/canvasSavedToGaller
import { addControlNetAutoProcessListener } from './listeners/controlNetAutoProcess';
import { addControlNetImageProcessedListener } from './listeners/controlNetImageProcessed';
import {
addImageDTOReceivedFulfilledListener,
addImageDTOReceivedRejectedListener,
} from './listeners/imageDTOReceived';
addImageAddedToBoardFulfilledListener,
addImageAddedToBoardRejectedListener,
} from './listeners/imageAddedToBoard';
import {
addImageDeletedFulfilledListener,
addImageDeletedPendingListener,
@@ -30,6 +28,14 @@ import {
addRequestedImageDeletionListener,
} from './listeners/imageDeleted';
import { addImageDroppedListener } from './listeners/imageDropped';
import {
addImageMetadataReceivedFulfilledListener,
addImageMetadataReceivedRejectedListener,
} from './listeners/imageMetadataReceived';
import {
addImageRemovedFromBoardFulfilledListener,
addImageRemovedFromBoardRejectedListener,
} from './listeners/imageRemovedFromBoard';
import { addImageToDeleteSelectedListener } from './listeners/imageToDeleteSelected';
import {
addImageUpdatedFulfilledListener,
@@ -39,11 +45,18 @@ import {
addImageUploadedFulfilledListener,
addImageUploadedRejectedListener,
} from './listeners/imageUploaded';
import { addImagesLoadedListener } from './listeners/imagesLoaded';
import {
addImageUrlsReceivedFulfilledListener,
addImageUrlsReceivedRejectedListener,
} from './listeners/imageUrlsReceived';
import { addInitialImageSelectedListener } from './listeners/initialImageSelected';
import { addModelSelectedListener } from './listeners/modelSelected';
import { addReceivedOpenAPISchemaListener } from './listeners/receivedOpenAPISchema';
import { addReceivedPageOfImagesListener } from './listeners/receivedPageOfImages';
import {
addReceivedPageOfImagesFulfilledListener,
addReceivedPageOfImagesRejectedListener,
} from './listeners/receivedPageOfImages';
import { addSelectionAddedToBatchListener } from './listeners/selectionAddedToBatch';
import {
addSessionCanceledFulfilledListener,
addSessionCanceledPendingListener,
@@ -119,8 +132,12 @@ addRequestedBoardImageDeletionListener();
addImageToDeleteSelectedListener();
// Image metadata
addImageDTOReceivedFulfilledListener();
addImageDTOReceivedRejectedListener();
addImageMetadataReceivedFulfilledListener();
addImageMetadataReceivedRejectedListener();
// Image URLs
addImageUrlsReceivedFulfilledListener();
addImageUrlsReceivedRejectedListener();
// User Invoked
addUserInvokedCanvasListener();
@@ -176,8 +193,8 @@ addSessionCanceledFulfilledListener();
addSessionCanceledRejectedListener();
// Fetching images
addReceivedPageOfImagesListener();
addImagesLoadedListener();
addReceivedPageOfImagesFulfilledListener();
addReceivedPageOfImagesRejectedListener();
// ControlNet
addControlNetImageProcessedListener();
@@ -187,15 +204,17 @@ addControlNetAutoProcessListener();
// addUpdateImageUrlsOnConnectListener();
// Boards
addBoardApiListeners();
addImageAddedToBoardFulfilledListener();
addImageAddedToBoardRejectedListener();
addImageRemovedFromBoardFulfilledListener();
addImageRemovedFromBoardRejectedListener();
addBoardIdSelectedListener();
// Node schemas
addReceivedOpenAPISchemaListener();
// Batches
// addSelectionAddedToBatchListener();
addAddBoardToBatchListener();
addSelectionAddedToBatchListener();
// DND
addImageDroppedListener();

View File

@@ -1,105 +0,0 @@
import { log } from 'app/logging/useLogger';
import { boardImagesApi } from 'services/api/endpoints/boardImages';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'boards' });
export const addBoardApiListeners = () => {
// add image to board - fulfilled
startAppListening({
matcher: boardImagesApi.endpoints.addBoardImage.matchFulfilled,
effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_name } },
'Image added to board'
);
},
});
// add image to board - rejected
startAppListening({
matcher: boardImagesApi.endpoints.addBoardImage.matchRejected,
effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_name } },
'Problem adding image to board'
);
},
});
// remove image from board - fulfilled
startAppListening({
matcher: boardImagesApi.endpoints.deleteBoardImage.matchFulfilled,
effect: (action, { getState, dispatch }) => {
const { image_name } = action.meta.arg.originalArgs;
moduleLog.debug({ data: { image_name } }, 'Image removed from board');
},
});
// remove image from board - rejected
startAppListening({
matcher: boardImagesApi.endpoints.deleteBoardImage.matchRejected,
effect: (action, { getState, dispatch }) => {
const image_name = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { image_name } },
'Problem removing image from board'
);
},
});
// many images added to board - fulfilled
startAppListening({
matcher: boardImagesApi.endpoints.addManyBoardImages.matchFulfilled,
effect: (action, { getState, dispatch }) => {
const { board_id, image_names } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_names } },
'Images added to board'
);
},
});
// many images added to board - rejected
startAppListening({
matcher: boardImagesApi.endpoints.addManyBoardImages.matchRejected,
effect: (action, { getState, dispatch }) => {
const { board_id, image_names } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_names } },
'Problem adding many images to board'
);
},
});
// remove many images from board - fulfilled
startAppListening({
matcher: boardImagesApi.endpoints.deleteManyBoardImages.matchFulfilled,
effect: (action, { getState, dispatch }) => {
const { image_names } = action.meta.arg.originalArgs;
moduleLog.debug({ data: { image_names } }, 'Images removed from board');
},
});
// remove many images from board - rejected
startAppListening({
matcher: boardImagesApi.endpoints.deleteManyBoardImages.matchRejected,
effect: (action, { getState, dispatch }) => {
const image_names = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { image_names } },
'Problem removing many images from board'
);
},
});
};

View File

@@ -1,31 +0,0 @@
import { createAction } from '@reduxjs/toolkit';
import { log } from 'app/logging/useLogger';
import { imagesAddedToBatch } from 'features/batch/store/batchSlice';
import { boardImageNamesReceived } from 'services/api/thunks/boardImages';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'batch' });
export const boardAddedToBatch = createAction<{ board_id: string }>(
'batch/boardAddedToBatch'
);
export const addAddBoardToBatchListener = () => {
startAppListening({
actionCreator: boardAddedToBatch,
effect: async (action, { dispatch, getState, take }) => {
const { board_id } = action.payload;
const { requestId } = dispatch(boardImageNamesReceived({ board_id }));
const [{ payload }] = await take(
(
action
): action is ReturnType<typeof boardImageNamesReceived.fulfilled> =>
action.meta.requestId === requestId
);
dispatch(imagesAddedToBatch(payload.image_names));
},
});
};

View File

@@ -1,4 +1,9 @@
import { createAction } from '@reduxjs/toolkit';
import {
INITIAL_IMAGE_LIMIT,
isLoadingChanged,
} from 'features/gallery/store/gallerySlice';
import { receivedPageOfImages } from 'services/api/thunks/image';
import { startAppListening } from '..';
export const appStarted = createAction('app/appStarted');
@@ -10,27 +15,29 @@ export const addAppStartedListener = () => {
action,
{ getState, dispatch, unsubscribe, cancelActiveListeners }
) => {
// cancelActiveListeners();
// unsubscribe();
// // fill up the gallery tab with images
// await dispatch(
// receivedPageOfImages({
// categories: ['general'],
// is_intermediate: false,
// offset: 0,
// // limit: INITIAL_IMAGE_LIMIT,
// })
// );
// // fill up the assets tab with images
// await dispatch(
// receivedPageOfImages({
// categories: ['control', 'mask', 'user', 'other'],
// is_intermediate: false,
// offset: 0,
// // limit: INITIAL_IMAGE_LIMIT,
// })
// );
// dispatch(isLoadingChanged(false));
cancelActiveListeners();
unsubscribe();
// fill up the gallery tab with images
await dispatch(
receivedPageOfImages({
categories: ['general'],
is_intermediate: false,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
})
);
// fill up the assets tab with images
await dispatch(
receivedPageOfImages({
categories: ['control', 'mask', 'user', 'other'],
is_intermediate: false,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
})
);
dispatch(isLoadingChanged(false));
},
});
};

View File

@@ -1,6 +1,15 @@
import { log } from 'app/logging/useLogger';
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { startAppListening } from '..';
import {
imageSelected,
selectImagesAll,
boardIdSelected,
} from 'features/gallery/store/gallerySlice';
import {
IMAGES_PER_PAGE,
receivedPageOfImages,
} from 'services/api/thunks/image';
import { boardsApi } from 'services/api/endpoints/boards';
const moduleLog = log.child({ namespace: 'boards' });
@@ -8,40 +17,49 @@ export const addBoardIdSelectedListener = () => {
startAppListening({
actionCreator: boardIdSelected,
effect: (action, { getState, dispatch }) => {
// const board_id = action.payload;
// // we need to check if we need to fetch more images
// const state = getState();
// const allImages = selectImagesAll(state);
// if (!board_id) {
// // a board was unselected
// dispatch(imageSelected(allImages[0]?.image_name));
// return;
// }
// const { categories } = state.gallery;
// const filteredImages = allImages.filter((i) => {
// const isInCategory = categories.includes(i.image_category);
// const isInSelectedBoard = board_id ? i.board_id === board_id : true;
// return isInCategory && isInSelectedBoard;
// });
// // get the board from the cache
// const { data: boards } =
// boardsApi.endpoints.listAllBoards.select()(state);
// const board = boards?.find((b) => b.board_id === board_id);
// if (!board) {
// // can't find the board in cache...
// dispatch(imageSelected(allImages[0]?.image_name));
// return;
// }
// dispatch(imageSelected(board.cover_image_name ?? null));
// // if we haven't loaded one full page of images from this board, load more
// if (
// filteredImages.length < board.image_count &&
// filteredImages.length < IMAGES_PER_PAGE
// ) {
// dispatch(
// receivedPageOfImages({ categories, board_id, is_intermediate: false })
// );
// }
const board_id = action.payload;
// we need to check if we need to fetch more images
const state = getState();
const allImages = selectImagesAll(state);
if (!board_id) {
// a board was unselected
dispatch(imageSelected(allImages[0]?.image_name));
return;
}
const { categories } = state.gallery;
const filteredImages = allImages.filter((i) => {
const isInCategory = categories.includes(i.image_category);
const isInSelectedBoard = board_id ? i.board_id === board_id : true;
return isInCategory && isInSelectedBoard;
});
// get the board from the cache
const { data: boards } =
boardsApi.endpoints.listAllBoards.select()(state);
const board = boards?.find((b) => b.board_id === board_id);
if (!board) {
// can't find the board in cache...
dispatch(imageSelected(allImages[0]?.image_name));
return;
}
dispatch(imageSelected(board.cover_image_name ?? null));
// if we haven't loaded one full page of images from this board, load more
if (
filteredImages.length < board.image_count &&
filteredImages.length < IMAGES_PER_PAGE
) {
dispatch(
receivedPageOfImages({ categories, board_id, is_intermediate: false })
);
}
},
});
};
@@ -50,36 +68,43 @@ export const addBoardIdSelected_changeSelectedImage_listener = () => {
startAppListening({
actionCreator: boardIdSelected,
effect: (action, { getState, dispatch }) => {
// const board_id = action.payload;
// const state = getState();
// // we need to check if we need to fetch more images
// if (!board_id) {
// // a board was unselected - we don't need to do anything
// return;
// }
// const { categories } = state.gallery;
// const filteredImages = selectImagesAll(state).filter((i) => {
// const isInCategory = categories.includes(i.image_category);
// const isInSelectedBoard = board_id ? i.board_id === board_id : true;
// return isInCategory && isInSelectedBoard;
// });
// // get the board from the cache
// const { data: boards } =
// boardsApi.endpoints.listAllBoards.select()(state);
// const board = boards?.find((b) => b.board_id === board_id);
// if (!board) {
// // can't find the board in cache...
// return;
// }
// // if we haven't loaded one full page of images from this board, load more
// if (
// filteredImages.length < board.image_count &&
// filteredImages.length < IMAGES_PER_PAGE
// ) {
// dispatch(
// receivedPageOfImages({ categories, board_id, is_intermediate: false })
// );
// }
const board_id = action.payload;
const state = getState();
// we need to check if we need to fetch more images
if (!board_id) {
// a board was unselected - we don't need to do anything
return;
}
const { categories } = state.gallery;
const filteredImages = selectImagesAll(state).filter((i) => {
const isInCategory = categories.includes(i.image_category);
const isInSelectedBoard = board_id ? i.board_id === board_id : true;
return isInCategory && isInSelectedBoard;
});
// get the board from the cache
const { data: boards } =
boardsApi.endpoints.listAllBoards.select()(state);
const board = boards?.find((b) => b.board_id === board_id);
if (!board) {
// can't find the board in cache...
return;
}
// if we haven't loaded one full page of images from this board, load more
if (
filteredImages.length < board.image_count &&
filteredImages.length < IMAGES_PER_PAGE
) {
dispatch(
receivedPageOfImages({ categories, board_id, is_intermediate: false })
);
}
},
});
};

View File

@@ -1,19 +1,21 @@
import { resetCanvas } from 'features/canvas/store/canvasSlice';
import { controlNetReset } from 'features/controlNet/store/controlNetSlice';
import { requestedBoardImagesDeletion as requestedBoardAndImagesDeletion } from 'features/gallery/store/actions';
import { requestedBoardImagesDeletion } from 'features/gallery/store/actions';
import { startAppListening } from '..';
import {
imageSelected,
imagesRemoved,
selectImagesAll,
selectImagesById,
} from 'features/gallery/store/gallerySlice';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { resetCanvas } from 'features/canvas/store/canvasSlice';
import { controlNetReset } from 'features/controlNet/store/controlNetSlice';
import { clearInitialImage } from 'features/parameters/store/generationSlice';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { LIST_TAG, api } from 'services/api';
import { startAppListening } from '..';
import { boardsApi } from '../../../../../services/api/endpoints/boards';
export const addRequestedBoardImageDeletionListener = () => {
startAppListening({
actionCreator: requestedBoardAndImagesDeletion,
actionCreator: requestedBoardImagesDeletion,
effect: async (action, { dispatch, getState, condition }) => {
const { board, imagesUsage } = action.payload;
@@ -49,12 +51,20 @@ export const addRequestedBoardImageDeletionListener = () => {
dispatch(nodeEditorReset());
}
// Preemptively remove from gallery
const images = selectImagesAll(state).reduce((acc: string[], img) => {
if (img.board_id === board_id) {
acc.push(img.image_name);
}
return acc;
}, []);
dispatch(imagesRemoved(images));
// Delete from server
dispatch(boardsApi.endpoints.deleteBoardAndImages.initiate(board_id));
const result =
boardsApi.endpoints.deleteBoardAndImages.select(board_id)(state);
const { isSuccess, data } = result;
const { isSuccess } = result;
// Wait for successful deletion, then trigger boards to re-fetch
const wasBoardDeleted = await condition(() => !!isSuccess, 30000);

View File

@@ -1,10 +1,10 @@
import { log } from 'app/logging/useLogger';
import { canvasSavedToGallery } from 'features/canvas/store/actions';
import { startAppListening } from '..';
import { log } from 'app/logging/useLogger';
import { imageUploaded } from 'services/api/thunks/image';
import { getBaseLayerBlob } from 'features/canvas/util/getBaseLayerBlob';
import { addToast } from 'features/system/store/systemSlice';
import { imagesApi } from 'services/api/endpoints/images';
import { imageUploaded } from 'services/api/thunks/image';
import { startAppListening } from '..';
import { imageUpserted } from 'features/gallery/store/gallerySlice';
const moduleLog = log.child({ namespace: 'canvasSavedToGalleryListener' });
@@ -49,11 +49,7 @@ export const addCanvasSavedToGalleryListener = () => {
uploadedImageAction.meta.requestId === imageUploadedRequest.requestId
);
imagesApi.util.upsertQueryData(
'getImageDTO',
uploadedImageDTO.image_name,
uploadedImageDTO
);
dispatch(imageUpserted(uploadedImageDTO));
},
});
};

View File

@@ -1,13 +1,13 @@
import { startAppListening } from '..';
import { imageMetadataReceived } from 'services/api/thunks/image';
import { log } from 'app/logging/useLogger';
import { controlNetImageProcessed } from 'features/controlNet/store/actions';
import { controlNetProcessedImageChanged } from 'features/controlNet/store/controlNetSlice';
import { sessionReadyToInvoke } from 'features/system/store/actions';
import { isImageOutput } from 'services/api/guards';
import { imageDTOReceived } from 'services/api/thunks/image';
import { sessionCreated } from 'services/api/thunks/session';
import { Graph } from 'services/api/types';
import { sessionCreated } from 'services/api/thunks/session';
import { sessionReadyToInvoke } from 'features/system/store/actions';
import { socketInvocationComplete } from 'services/events/actions';
import { startAppListening } from '..';
import { isImageOutput } from 'services/api/guards';
import { controlNetProcessedImageChanged } from 'features/controlNet/store/controlNetSlice';
const moduleLog = log.child({ namespace: 'controlNet' });
@@ -63,8 +63,10 @@ export const addControlNetImageProcessedListener = () => {
// Wait for the ImageDTO to be received
const [imageMetadataReceivedAction] = await take(
(action): action is ReturnType<typeof imageDTOReceived.fulfilled> =>
imageDTOReceived.fulfilled.match(action) &&
(
action
): action is ReturnType<typeof imageMetadataReceived.fulfilled> =>
imageMetadataReceived.fulfilled.match(action) &&
action.payload.image_name === image_name
);
const processedControlImage = imageMetadataReceivedAction.payload;

View File

@@ -0,0 +1,40 @@
import { log } from 'app/logging/useLogger';
import { startAppListening } from '..';
import { imageMetadataReceived } from 'services/api/thunks/image';
import { boardImagesApi } from 'services/api/endpoints/boardImages';
const moduleLog = log.child({ namespace: 'boards' });
export const addImageAddedToBoardFulfilledListener = () => {
startAppListening({
matcher: boardImagesApi.endpoints.addImageToBoard.matchFulfilled,
effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_name } },
'Image added to board'
);
dispatch(
imageMetadataReceived({
image_name,
})
);
},
});
};
export const addImageAddedToBoardRejectedListener = () => {
startAppListening({
matcher: boardImagesApi.endpoints.addImageToBoard.matchRejected,
effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_name } },
'Problem adding image to board'
);
},
});
};

View File

@@ -1,8 +1,11 @@
import { log } from 'app/logging/useLogger';
import { resetCanvas } from 'features/canvas/store/canvasSlice';
import { controlNetReset } from 'features/controlNet/store/controlNetSlice';
import { selectFilteredImages } from 'features/gallery/store/gallerySelectors';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import {
imageRemoved,
imageSelected,
selectFilteredImages,
} from 'features/gallery/store/gallerySlice';
import {
imageDeletionConfirmed,
isModalOpenChanged,
@@ -77,6 +80,9 @@ export const addRequestedImageDeletionListener = () => {
dispatch(nodeEditorReset());
}
// Preemptively remove from gallery
dispatch(imageRemoved(image_name));
// Delete from server
const { requestId } = dispatch(imageDeleted({ image_name }));
@@ -85,7 +91,7 @@ export const addRequestedImageDeletionListener = () => {
(action): action is ReturnType<typeof imageDeleted.fulfilled> =>
imageDeleted.fulfilled.match(action) &&
action.meta.requestId === requestId,
30_000
30000
);
if (wasImageDeleted) {

View File

@@ -21,66 +21,57 @@ import { startAppListening } from '../';
const moduleLog = log.child({ namespace: 'dnd' });
export const dndDropped = createAction<{
export const imageDropped = createAction<{
overData: TypesafeDroppableData;
activeData: TypesafeDraggableData;
}>('dnd/dndDropped');
}>('dnd/imageDropped');
export const addImageDroppedListener = () => {
startAppListening({
actionCreator: dndDropped,
effect: async (action, { dispatch, getState, take }) => {
actionCreator: imageDropped,
effect: (action, { dispatch, getState }) => {
const { activeData, overData } = action.payload;
const { actionType } = overData;
const state = getState();
moduleLog.debug(
{ data: { activeData, overData } },
'Image or selection dropped'
);
// set current image
if (
overData.actionType === 'SET_CURRENT_IMAGE' &&
actionType === 'SET_CURRENT_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
dispatch(imageSelected(activeData.payload.imageDTO.image_name));
return;
}
// set initial image
if (
overData.actionType === 'SET_INITIAL_IMAGE' &&
actionType === 'SET_INITIAL_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
dispatch(initialImageChanged(activeData.payload.imageDTO));
return;
}
// add image to batch
if (
overData.actionType === 'ADD_TO_BATCH' &&
actionType === 'ADD_TO_BATCH' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
dispatch(imageAddedToBatch(activeData.payload.imageDTO.image_name));
return;
}
// add multiple images to batch
if (
overData.actionType === 'ADD_TO_BATCH' &&
activeData.payloadType === 'IMAGE_NAMES'
actionType === 'ADD_TO_BATCH' &&
activeData.payloadType === 'GALLERY_SELECTION'
) {
dispatch(imagesAddedToBatch(activeData.payload.image_names));
return;
dispatch(imagesAddedToBatch(state.gallery.selection));
}
// set control image
if (
overData.actionType === 'SET_CONTROLNET_IMAGE' &&
actionType === 'SET_CONTROLNET_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
@@ -91,22 +82,20 @@ export const addImageDroppedListener = () => {
controlNetId,
})
);
return;
}
// set canvas image
if (
overData.actionType === 'SET_CANVAS_INITIAL_IMAGE' &&
actionType === 'SET_CANVAS_INITIAL_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
dispatch(setInitialCanvasImage(activeData.payload.imageDTO));
return;
}
// set nodes image
if (
overData.actionType === 'SET_NODES_IMAGE' &&
actionType === 'SET_NODES_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
@@ -118,12 +107,11 @@ export const addImageDroppedListener = () => {
value: activeData.payload.imageDTO,
})
);
return;
}
// set multiple nodes images (single image handler)
if (
overData.actionType === 'SET_MULTI_NODES_IMAGE' &&
actionType === 'SET_MULTI_NODES_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
@@ -135,30 +123,43 @@ export const addImageDroppedListener = () => {
value: [activeData.payload.imageDTO],
})
);
return;
}
// set multiple nodes images (multiple images handler)
if (
overData.actionType === 'SET_MULTI_NODES_IMAGE' &&
activeData.payloadType === 'IMAGE_NAMES'
actionType === 'SET_MULTI_NODES_IMAGE' &&
activeData.payloadType === 'GALLERY_SELECTION'
) {
const { fieldName, nodeId } = overData.context;
dispatch(
imageCollectionFieldValueChanged({
nodeId,
fieldName,
value: activeData.payload.image_names.map((image_name) => ({
value: state.gallery.selection.map((image_name) => ({
image_name,
})),
})
);
return;
}
// remove image from board
// TODO: remove board_id from `removeImageFromBoard()` endpoint
// TODO: handle multiple images
// if (
// actionType === 'MOVE_BOARD' &&
// activeData.payloadType === 'IMAGE_DTO' &&
// activeData.payload.imageDTO &&
// overData.boardId !== null
// ) {
// const { image_name } = activeData.payload.imageDTO;
// dispatch(
// boardImagesApi.endpoints.removeImageFromBoard.initiate({ image_name })
// );
// }
// add image to board
if (
overData.actionType === 'MOVE_BOARD' &&
actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO &&
overData.context.boardId
@@ -166,89 +167,22 @@ export const addImageDroppedListener = () => {
const { image_name } = activeData.payload.imageDTO;
const { boardId } = overData.context;
dispatch(
boardImagesApi.endpoints.addBoardImage.initiate({
boardImagesApi.endpoints.addImageToBoard.initiate({
image_name,
board_id: boardId,
})
);
return;
}
// remove image from board
if (
overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO &&
overData.context.boardId === null
) {
const { image_name } = activeData.payload.imageDTO;
dispatch(
boardImagesApi.endpoints.deleteBoardImage.initiate({ image_name })
);
return;
}
// add gallery selection to board
if (
overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_NAMES' &&
overData.context.boardId
) {
console.log('adding gallery selection to board');
const board_id = overData.context.boardId;
dispatch(
boardImagesApi.endpoints.addManyBoardImages.initiate({
board_id,
image_names: activeData.payload.image_names,
})
);
return;
}
// remove gallery selection from board
if (
overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_NAMES' &&
overData.context.boardId === null
) {
console.log('removing gallery selection to board');
dispatch(
boardImagesApi.endpoints.deleteManyBoardImages.initiate({
image_names: activeData.payload.image_names,
})
);
return;
}
// add batch selection to board
if (
overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_NAMES' &&
overData.context.boardId
) {
const board_id = overData.context.boardId;
dispatch(
boardImagesApi.endpoints.addManyBoardImages.initiate({
board_id,
image_names: activeData.payload.image_names,
})
);
return;
}
// remove batch selection from board
if (
overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_NAMES' &&
overData.context.boardId === null
) {
dispatch(
boardImagesApi.endpoints.deleteManyBoardImages.initiate({
image_names: activeData.payload.image_names,
})
);
return;
}
// add multiple images to board
// TODO: add endpoint
// if (
// actionType === 'ADD_TO_BATCH' &&
// activeData.payloadType === 'IMAGE_NAMES' &&
// activeData.payload.imageDTONames
// ) {
// dispatch(boardImagesApi.endpoints.addImagesToBoard.intiate({}));
// }
},
});
};

View File

@@ -1,13 +1,13 @@
import { log } from 'app/logging/useLogger';
import { imagesApi } from 'services/api/endpoints/images';
import { imageDTOReceived, imageUpdated } from 'services/api/thunks/image';
import { startAppListening } from '..';
import { imageMetadataReceived, imageUpdated } from 'services/api/thunks/image';
import { imageUpserted } from 'features/gallery/store/gallerySlice';
const moduleLog = log.child({ namespace: 'image' });
export const addImageDTOReceivedFulfilledListener = () => {
export const addImageMetadataReceivedFulfilledListener = () => {
startAppListening({
actionCreator: imageDTOReceived.fulfilled,
actionCreator: imageMetadataReceived.fulfilled,
effect: (action, { getState, dispatch }) => {
const image = action.payload;
@@ -33,14 +33,14 @@ export const addImageDTOReceivedFulfilledListener = () => {
}
moduleLog.debug({ data: { image } }, 'Image metadata received');
imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image);
dispatch(imageUpserted(image));
},
});
};
export const addImageDTOReceivedRejectedListener = () => {
export const addImageMetadataReceivedRejectedListener = () => {
startAppListening({
actionCreator: imageDTOReceived.rejected,
actionCreator: imageMetadataReceived.rejected,
effect: (action, { getState, dispatch }) => {
moduleLog.debug(
{ data: { image: action.meta.arg } },

View File

@@ -0,0 +1,40 @@
import { log } from 'app/logging/useLogger';
import { startAppListening } from '..';
import { imageMetadataReceived } from 'services/api/thunks/image';
import { boardImagesApi } from 'services/api/endpoints/boardImages';
const moduleLog = log.child({ namespace: 'boards' });
export const addImageRemovedFromBoardFulfilledListener = () => {
startAppListening({
matcher: boardImagesApi.endpoints.removeImageFromBoard.matchFulfilled,
effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_name } },
'Image added to board'
);
dispatch(
imageMetadataReceived({
image_name,
})
);
},
});
};
export const addImageRemovedFromBoardRejectedListener = () => {
startAppListening({
matcher: boardImagesApi.endpoints.removeImageFromBoard.matchRejected,
effect: (action, { getState, dispatch }) => {
const { board_id, image_name } = action.meta.arg.originalArgs;
moduleLog.debug(
{ data: { board_id, image_name } },
'Problem adding image to board'
);
},
});
};

View File

@@ -1,13 +1,13 @@
import { startAppListening } from '..';
import { imageUploaded } from 'services/api/thunks/image';
import { addToast } from 'features/system/store/systemSlice';
import { log } from 'app/logging/useLogger';
import { imageAddedToBatch } from 'features/batch/store/batchSlice';
import { imageUpserted } from 'features/gallery/store/gallerySlice';
import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice';
import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { addToast } from 'features/system/store/systemSlice';
import { imagesApi } from 'services/api/endpoints/images';
import { imageUploaded } from 'services/api/thunks/image';
import { startAppListening } from '..';
import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
import { imageAddedToBatch } from 'features/batch/store/batchSlice';
const moduleLog = log.child({ namespace: 'image' });
@@ -24,8 +24,7 @@ export const addImageUploadedFulfilledListener = () => {
return;
}
// update RTK query cache
imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image);
dispatch(imageUpserted(image));
const { postUploadAction } = action.meta.arg;
@@ -85,8 +84,8 @@ export const addImageUploadedRejectedListener = () => {
startAppListening({
actionCreator: imageUploaded.rejected,
effect: (action, { dispatch }) => {
const { file, ...rest } = action.meta.arg;
const sanitizedData = { arg: { ...rest, file: '<Blob>' } };
const { formData, ...rest } = action.meta.arg;
const sanitizedData = { arg: { ...rest, formData: { file: '<Blob>' } } };
moduleLog.error({ data: sanitizedData }, 'Image upload failed');
dispatch(
addToast({

View File

@@ -0,0 +1,37 @@
import { log } from 'app/logging/useLogger';
import { startAppListening } from '..';
import { imageUrlsReceived } from 'services/api/thunks/image';
import { imageUpdatedOne } from 'features/gallery/store/gallerySlice';
const moduleLog = log.child({ namespace: 'image' });
export const addImageUrlsReceivedFulfilledListener = () => {
startAppListening({
actionCreator: imageUrlsReceived.fulfilled,
effect: (action, { getState, dispatch }) => {
const image = action.payload;
moduleLog.debug({ data: { image } }, 'Image URLs received');
const { image_name, image_url, thumbnail_url } = image;
dispatch(
imageUpdatedOne({
id: image_name,
changes: { image_url, thumbnail_url },
})
);
},
});
};
export const addImageUrlsReceivedRejectedListener = () => {
startAppListening({
actionCreator: imageUrlsReceived.rejected,
effect: (action, { getState, dispatch }) => {
moduleLog.debug(
{ data: { image: action.meta.arg } },
'Problem getting image URLs'
);
},
});
};

View File

@@ -1,38 +0,0 @@
import { log } from 'app/logging/useLogger';
import { serializeError } from 'serialize-error';
import { imagesApi } from 'services/api/endpoints/images';
import { imagesLoaded } from 'services/api/thunks/image';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'gallery' });
export const addImagesLoadedListener = () => {
startAppListening({
actionCreator: imagesLoaded.fulfilled,
effect: (action, { getState, dispatch }) => {
const { items } = action.payload;
moduleLog.debug(
{ data: { payload: action.payload } },
`Loaded ${items.length} images`
);
items.forEach((image) => {
dispatch(
imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image)
);
});
},
});
startAppListening({
actionCreator: imagesLoaded.rejected,
effect: (action, { getState, dispatch }) => {
if (action.payload) {
moduleLog.debug(
{ data: { error: serializeError(action.payload) } },
'Problem loading images'
);
}
},
});
};

View File

@@ -1,38 +0,0 @@
import { log } from 'app/logging/useLogger';
import { serializeError } from 'serialize-error';
import { imagesApi } from 'services/api/endpoints/images';
import { receivedListOfImages } from 'services/api/thunks/image';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'gallery' });
export const addReceivedListOfImagesListener = () => {
startAppListening({
actionCreator: receivedListOfImages.fulfilled,
effect: (action, { getState, dispatch }) => {
const { image_dtos } = action.payload;
moduleLog.debug(
{ data: { payload: action.payload } },
`Received ${image_dtos.length} images`
);
image_dtos.forEach((image) => {
dispatch(
imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image)
);
});
},
});
startAppListening({
actionCreator: receivedListOfImages.rejected,
effect: (action, { getState, dispatch }) => {
if (action.payload) {
moduleLog.debug(
{ data: { error: serializeError(action.payload) } },
'Problem receiving images'
);
}
},
});
};

View File

@@ -1,12 +1,12 @@
import { log } from 'app/logging/useLogger';
import { serializeError } from 'serialize-error';
import { imagesApi } from 'services/api/endpoints/images';
import { receivedPageOfImages } from 'services/api/thunks/image';
import { startAppListening } from '..';
import { serializeError } from 'serialize-error';
import { receivedPageOfImages } from 'services/api/thunks/image';
import { imagesApi } from 'services/api/endpoints/images';
const moduleLog = log.child({ namespace: 'gallery' });
export const addReceivedPageOfImagesListener = () => {
export const addReceivedPageOfImagesFulfilledListener = () => {
startAppListening({
actionCreator: receivedPageOfImages.fulfilled,
effect: (action, { getState, dispatch }) => {
@@ -16,8 +16,6 @@ export const addReceivedPageOfImagesListener = () => {
`Received ${items.length} images`
);
// inject the received images into the RTK Query cache so consumers of the useGetImageDTOQuery
// hook can get their data from the cache instead of fetching the data again
items.forEach((image) => {
dispatch(
imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image)
@@ -25,7 +23,9 @@ export const addReceivedPageOfImagesListener = () => {
});
},
});
};
export const addReceivedPageOfImagesRejectedListener = () => {
startAppListening({
actionCreator: receivedPageOfImages.rejected,
effect: (action, { getState, dispatch }) => {

View File

@@ -1,38 +1,19 @@
import { createAction } from '@reduxjs/toolkit';
import { log } from 'app/logging/useLogger';
import { imagesAddedToBatch } from 'features/batch/store/batchSlice';
import { imagesApi } from 'services/api/endpoints/images';
import { receivedListOfImages } from 'services/api/thunks/image';
import { startAppListening } from '..';
import { log } from 'app/logging/useLogger';
import {
imagesAddedToBatch,
selectionAddedToBatch,
} from 'features/batch/store/batchSlice';
const moduleLog = log.child({ namespace: 'batch' });
export const selectionAddedToBatch = createAction<{ images_names: string[] }>(
'batch/selectionAddedToBatch'
);
export const addSelectionAddedToBatchListener = () => {
startAppListening({
actionCreator: selectionAddedToBatch,
effect: async (action, { dispatch, getState, take }) => {
const { requestId } = dispatch(
receivedListOfImages(action.payload.images_names)
);
effect: (action, { dispatch, getState }) => {
const { selection } = getState().gallery;
const [{ payload }] = await take(
(action): action is ReturnType<typeof receivedListOfImages.fulfilled> =>
action.meta.requestId === requestId
);
moduleLog.debug({ data: { payload } }, 'receivedListOfImages');
dispatch(imagesAddedToBatch(payload.image_dtos));
payload.image_dtos.forEach((image) => {
dispatch(
imagesApi.util.upsertQueryData('getImageDTO', image.image_name, image)
);
});
dispatch(imagesAddedToBatch(selection));
},
});
};

View File

@@ -30,7 +30,6 @@ export const addSessionCreatedRejectedListener = () => {
effect: (action, { getState, dispatch }) => {
if (action.payload) {
const { arg, error } = action.payload;
const stringifiedError = JSON.stringify(error);
moduleLog.error(
{
data: {
@@ -38,7 +37,7 @@ export const addSessionCreatedRejectedListener = () => {
error: serializeError(error),
},
},
`Problem creating session: ${stringifiedError}`
`Problem creating session`
);
}
},

View File

@@ -33,7 +33,6 @@ export const addSessionInvokedRejectedListener = () => {
effect: (action, { getState, dispatch }) => {
if (action.payload) {
const { arg, error } = action.payload;
const stringifiedError = JSON.stringify(error);
moduleLog.error(
{
data: {
@@ -41,7 +40,7 @@ export const addSessionInvokedRejectedListener = () => {
error: serializeError(error),
},
},
`Problem invoking session: ${stringifiedError}`
`Problem invoking session`
);
}
},

View File

@@ -1,16 +1,15 @@
import { log } from 'app/logging/useLogger';
import { addImageToStagingArea } from 'features/canvas/store/canvasSlice';
import { progressImageSet } from 'features/system/store/systemSlice';
import { boardImagesApi } from 'services/api/endpoints/boardImages';
import { imagesApi } from 'services/api/endpoints/images';
import { isImageOutput } from 'services/api/guards';
import { imageDTOReceived } from 'services/api/thunks/image';
import { sessionCanceled } from 'services/api/thunks/session';
import { startAppListening } from '../..';
import { log } from 'app/logging/useLogger';
import {
appSocketInvocationComplete,
socketInvocationComplete,
} from 'services/events/actions';
import { startAppListening } from '../..';
import { imageMetadataReceived } from 'services/api/thunks/image';
import { sessionCanceled } from 'services/api/thunks/session';
import { isImageOutput } from 'services/api/guards';
import { progressImageSet } from 'features/system/store/systemSlice';
import { boardImagesApi } from 'services/api/endpoints/boardImages';
const moduleLog = log.child({ namespace: 'socketio' });
const nodeDenylist = ['dataURL_image'];
@@ -42,16 +41,14 @@ export const addInvocationCompleteEventListener = () => {
const { image_name } = result.image;
// Get its metadata
const { requestId } = dispatch(
imageDTOReceived({
dispatch(
imageMetadataReceived({
image_name,
})
);
const [{ payload: imageDTO }] = await take(
(action): action is ReturnType<typeof imageDTOReceived.fulfilled> =>
imageDTOReceived.fulfilled.match(action) &&
action.meta.requestId === requestId
imageMetadataReceived.fulfilled.match
);
// Handle canvas image
@@ -62,33 +59,13 @@ export const addInvocationCompleteEventListener = () => {
dispatch(addImageToStagingArea(imageDTO));
}
// Update the RTK Query cache
dispatch(
imagesApi.util.upsertQueryData(
'getImageDTO',
imageDTO.image_name,
imageDTO
)
);
if (boardIdToAddTo && !imageDTO.is_intermediate) {
dispatch(
boardImagesApi.endpoints.addBoardImage.initiate({
boardImagesApi.endpoints.addImageToBoard.initiate({
board_id: boardIdToAddTo,
image_name,
})
);
// Set the board_id on the image in the RTK Query cache
dispatch(
imagesApi.util.updateQueryData(
'getImageDTO',
imageDTO.image_name,
(draft) => {
Object.assign(draft, { board_id: boardIdToAddTo });
}
)
);
}
dispatch(progressImageSet(null));

View File

@@ -13,7 +13,7 @@ export const addInvocationErrorEventListener = () => {
effect: (action, { dispatch, getState }) => {
moduleLog.error(
action.payload,
`Invocation error (${action.payload.data.node.type}): ${action.payload.data.error}`
`Invocation error (${action.payload.data.node.type})`
);
dispatch(appSocketInvocationError(action.payload));
},

View File

@@ -1,9 +1,9 @@
import { log } from 'app/logging/useLogger';
import { stagingAreaImageSaved } from 'features/canvas/store/actions';
import { addToast } from 'features/system/store/systemSlice';
import { imagesApi } from 'services/api/endpoints/images';
import { imageUpdated } from 'services/api/thunks/image';
import { startAppListening } from '..';
import { log } from 'app/logging/useLogger';
import { imageUpdated } from 'services/api/thunks/image';
import { imageUpserted } from 'features/gallery/store/gallerySlice';
import { addToast } from 'features/system/store/systemSlice';
const moduleLog = log.child({ namespace: 'canvas' });
@@ -43,10 +43,7 @@ export const addStagingAreaImageSavedListener = () => {
}
if (imageUpdated.fulfilled.match(imageUpdatedAction)) {
// update cache
imagesApi.util.updateQueryData('getImageDTO', imageName, (draft) => {
Object.assign(draft, { is_intermediate: false });
});
dispatch(imageUpserted(imageUpdatedAction.payload));
dispatch(addToast({ title: 'Image Saved', status: 'success' }));
}
},

View File

@@ -96,26 +96,10 @@ export const store = configureStore({
.concat(dynamicMiddlewares)
.prepend(listenerMiddleware.middleware),
devTools: {
actionsDenylist,
actionSanitizer,
stateSanitizer,
trace: true,
predicate: (state, action) => {
// TODO: hook up to the log level param in system slice
// manually type state, cannot type the arg
// const typedState = state as ReturnType<typeof rootReducer>;
// if (action.type.startsWith('api/')) {
// // don't log api actions, with manual cache updates they are extremely noisy
// return false;
// }
if (actionsDenylist.includes(action.type)) {
// don't log other noisy actions
return false;
}
return true;
},
},
});

View File

@@ -102,8 +102,6 @@ export type AppFeature =
export type SDFeature =
| 'controlNet'
| 'noise'
| 'perlinNoise'
| 'noiseThreshold'
| 'variation'
| 'symmetry'
| 'seamless'

View File

@@ -6,24 +6,30 @@ import {
useColorMode,
useColorModeValue,
} from '@chakra-ui/react';
import {
TypesafeDraggableData,
TypesafeDroppableData,
} from 'app/components/ImageDnd/typesafeDnd';
import { useCombinedRefs } from '@dnd-kit/utilities';
import IAIIconButton from 'common/components/IAIIconButton';
import {
IAILoadingImageFallback,
IAINoContentFallback,
} from 'common/components/IAIImageFallback';
import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { MouseEvent, ReactElement, SyntheticEvent, memo } from 'react';
import { AnimatePresence } from 'framer-motion';
import { MouseEvent, ReactElement, SyntheticEvent } from 'react';
import { memo, useRef } from 'react';
import { FaImage, FaUndo, FaUpload } from 'react-icons/fa';
import { PostUploadAction } from 'services/api/thunks/image';
import { ImageDTO } from 'services/api/types';
import { v4 as uuidv4 } from 'uuid';
import IAIDropOverlay from './IAIDropOverlay';
import { PostUploadAction } from 'services/api/thunks/image';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { mode } from 'theme/util/mode';
import IAIDraggable from './IAIDraggable';
import IAIDroppable from './IAIDroppable';
import {
TypesafeDraggableData,
TypesafeDroppableData,
isValidDrop,
useDraggable,
useDroppable,
} from 'app/components/ImageDnd/typesafeDnd';
type IAIDndImageProps = {
imageDTO: ImageDTO | undefined;
@@ -77,6 +83,28 @@ const IAIDndImage = (props: IAIDndImageProps) => {
const { colorMode } = useColorMode();
const dndId = useRef(uuidv4());
const {
attributes,
listeners,
setNodeRef: setDraggableRef,
isDragging,
active,
} = useDraggable({
id: dndId.current,
disabled: isDragDisabled || !imageDTO,
data: draggableData,
});
const { isOver, setNodeRef: setDroppableRef } = useDroppable({
id: dndId.current,
disabled: isDropDisabled,
data: droppableData,
});
const setDndRef = useCombinedRefs(setDroppableRef, setDraggableRef);
const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
postUploadAction,
isDisabled: isUploadDisabled,
@@ -111,6 +139,9 @@ const IAIDndImage = (props: IAIDndImageProps) => {
userSelect: 'none',
cursor: isDragDisabled || !imageDTO ? 'default' : 'pointer',
}}
{...attributes}
{...listeners}
ref={setDndRef}
>
{imageDTO && (
<Flex
@@ -123,6 +154,7 @@ const IAIDndImage = (props: IAIDndImageProps) => {
}}
>
<Image
onClick={onClick}
src={thumbnail ? imageDTO.thumbnail_url : imageDTO.image_url}
fallbackStrategy="beforeLoadOrError"
fallback={<IAILoadingImageFallback image={imageDTO} />}
@@ -139,6 +171,30 @@ const IAIDndImage = (props: IAIDndImageProps) => {
}}
/>
{withMetadataOverlay && <ImageMetadataOverlay image={imageDTO} />}
{onClickReset && withResetIcon && (
<IAIIconButton
onClick={onClickReset}
aria-label={resetTooltip}
tooltip={resetTooltip}
icon={resetIcon}
size="sm"
variant="link"
sx={{
position: 'absolute',
top: 1,
insetInlineEnd: 1,
p: 0,
minW: 0,
svg: {
transitionProperty: 'common',
transitionDuration: 'normal',
fill: 'base.100',
_hover: { fill: 'base.50' },
filter: resetIconShadow,
},
}}
/>
)}
</Flex>
)}
{!imageDTO && !isUploadDisabled && (
@@ -169,42 +225,11 @@ const IAIDndImage = (props: IAIDndImageProps) => {
</>
)}
{!imageDTO && isUploadDisabled && noContentFallback}
<IAIDroppable
data={droppableData}
disabled={isDropDisabled}
dropLabel={dropLabel}
/>
{imageDTO && (
<IAIDraggable
data={draggableData}
disabled={isDragDisabled || !imageDTO}
onClick={onClick}
/>
)}
{onClickReset && withResetIcon && imageDTO && (
<IAIIconButton
onClick={onClickReset}
aria-label={resetTooltip}
tooltip={resetTooltip}
icon={resetIcon}
size="sm"
variant="link"
sx={{
position: 'absolute',
top: 1,
insetInlineEnd: 1,
p: 0,
minW: 0,
svg: {
transitionProperty: 'common',
transitionDuration: 'normal',
fill: 'base.100',
_hover: { fill: 'base.50' },
filter: resetIconShadow,
},
}}
/>
)}
<AnimatePresence>
{isValidDrop(droppableData, active) && !isDragging && (
<IAIDropOverlay isOver={isOver} label={dropLabel} />
)}
</AnimatePresence>
</Flex>
);
};

View File

@@ -1,40 +0,0 @@
import { Box } from '@chakra-ui/react';
import {
TypesafeDraggableData,
useDraggable,
} from 'app/components/ImageDnd/typesafeDnd';
import { MouseEvent, memo, useRef } from 'react';
import { v4 as uuidv4 } from 'uuid';
type IAIDraggableProps = {
disabled?: boolean;
data?: TypesafeDraggableData;
onClick?: (event: MouseEvent<HTMLDivElement>) => void;
};
const IAIDraggable = (props: IAIDraggableProps) => {
const { data, disabled, onClick } = props;
const dndId = useRef(uuidv4());
const { attributes, listeners, setNodeRef } = useDraggable({
id: dndId.current,
disabled,
data,
});
return (
<Box
onClick={onClick}
ref={setNodeRef}
position="absolute"
w="full"
h="full"
top={0}
insetInlineStart={0}
{...attributes}
{...listeners}
/>
);
};
export default memo(IAIDraggable);

View File

@@ -1,47 +0,0 @@
import { Box } from '@chakra-ui/react';
import {
TypesafeDroppableData,
isValidDrop,
useDroppable,
} from 'app/components/ImageDnd/typesafeDnd';
import { AnimatePresence } from 'framer-motion';
import { memo, useRef } from 'react';
import { v4 as uuidv4 } from 'uuid';
import IAIDropOverlay from './IAIDropOverlay';
type IAIDroppableProps = {
dropLabel?: string;
disabled?: boolean;
data?: TypesafeDroppableData;
};
const IAIDroppable = (props: IAIDroppableProps) => {
const { dropLabel, data, disabled } = props;
const dndId = useRef(uuidv4());
const { isOver, setNodeRef, active } = useDroppable({
id: dndId.current,
disabled,
data,
});
return (
<Box
ref={setNodeRef}
position="absolute"
top={0}
insetInlineStart={0}
w="full"
h="full"
pointerEvents="none"
>
<AnimatePresence>
{isValidDrop(data, active) && (
<IAIDropOverlay isOver={isOver} label={dropLabel} />
)}
</AnimatePresence>
</Box>
);
};
export default memo(IAIDroppable);

View File

@@ -1,42 +0,0 @@
import { Box, Flex, Icon } from '@chakra-ui/react';
import { FaExclamation } from 'react-icons/fa';
const IAIErrorLoadingImageFallback = () => {
return (
<Box
sx={{
position: 'relative',
height: 'full',
width: 'full',
'::before': {
content: "''",
display: 'block',
pt: '100%',
},
}}
>
<Flex
sx={{
position: 'absolute',
top: 0,
insetInlineStart: 0,
height: 'full',
width: 'full',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'base',
bg: 'base.100',
color: 'base.500',
_dark: {
color: 'base.700',
bg: 'base.850',
},
}}
>
<Icon as={FaExclamation} boxSize={16} opacity={0.7} />
</Flex>
</Box>
);
};
export default IAIErrorLoadingImageFallback;

View File

@@ -1,30 +0,0 @@
import { Box, Skeleton } from '@chakra-ui/react';
const IAIFillSkeleton = () => {
return (
<Skeleton
sx={{
position: 'relative',
height: 'full',
width: 'full',
'::before': {
content: "''",
display: 'block',
pt: '100%',
},
}}
>
<Box
sx={{
position: 'absolute',
top: 0,
insetInlineStart: 0,
height: 'full',
width: 'full',
}}
/>
</Skeleton>
);
};
export default IAIFillSkeleton;

View File

@@ -1,20 +1,18 @@
import { Box } from '@chakra-ui/react';
import { Box, Icon, Skeleton } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIDndImage from 'common/components/IAIDndImage';
import IAIErrorLoadingImageFallback from 'common/components/IAIErrorLoadingImageFallback';
import IAIFillSkeleton from 'common/components/IAIFillSkeleton';
import {
batchImageRangeEndSelected,
batchImageSelected,
batchImageSelectionToggled,
imageRemovedFromBatch,
} from 'features/batch/store/batchSlice';
import ImageContextMenu from 'features/gallery/components/ImageContextMenu';
import { MouseEvent, memo, useCallback, useMemo } from 'react';
import { FaExclamationCircle } from 'react-icons/fa';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
const makeSelector = (image_name: string) =>
@@ -22,7 +20,6 @@ const makeSelector = (image_name: string) =>
[stateSelector],
(state) => ({
selectionCount: state.batch.selection.length,
selection: state.batch.selection,
isSelected: state.batch.selection.includes(image_name),
}),
defaultSelectorOptions
@@ -33,41 +30,43 @@ type BatchImageProps = {
};
const BatchImage = (props: BatchImageProps) => {
const dispatch = useAppDispatch();
const { imageName } = props;
const {
currentData: imageDTO,
isLoading,
isFetching,
isError,
isSuccess,
} = useGetImageDTOQuery(imageName);
const selector = useMemo(() => makeSelector(imageName), [imageName]);
} = useGetImageDTOQuery(props.imageName);
const dispatch = useAppDispatch();
const { isSelected, selectionCount, selection } = useAppSelector(selector);
const selector = useMemo(
() => makeSelector(props.imageName),
[props.imageName]
);
const { isSelected, selectionCount } = useAppSelector(selector);
const handleClickRemove = useCallback(() => {
dispatch(imageRemovedFromBatch(imageName));
}, [dispatch, imageName]);
dispatch(imageRemovedFromBatch(props.imageName));
}, [dispatch, props.imageName]);
const handleClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
if (e.shiftKey) {
dispatch(batchImageRangeEndSelected(imageName));
dispatch(batchImageRangeEndSelected(props.imageName));
} else if (e.ctrlKey || e.metaKey) {
dispatch(batchImageSelectionToggled(imageName));
dispatch(batchImageSelectionToggled(props.imageName));
} else {
dispatch(batchImageSelected(imageName));
dispatch(batchImageSelected(props.imageName));
}
},
[dispatch, imageName]
[dispatch, props.imageName]
);
const draggableData = useMemo<TypesafeDraggableData | undefined>(() => {
if (selectionCount > 1) {
return {
id: 'batch',
payloadType: 'IMAGE_NAMES',
payload: { image_names: selection },
payloadType: 'BATCH_SELECTION',
};
}
@@ -78,49 +77,38 @@ const BatchImage = (props: BatchImageProps) => {
payload: { imageDTO },
};
}
}, [imageDTO, selection, selectionCount]);
}, [imageDTO, selectionCount]);
if (isLoading) {
return <IAIFillSkeleton />;
if (isError) {
return <Icon as={FaExclamationCircle} />;
}
if (isError || !imageDTO) {
return <IAIErrorLoadingImageFallback />;
if (isFetching) {
return (
<Skeleton>
<Box w="full" h="full" aspectRatio="1/1" />
</Skeleton>
);
}
return (
<Box sx={{ w: 'full', h: 'full', touchAction: 'none' }}>
<ImageContextMenu imageDTO={imageDTO}>
{(ref) => (
<Box
position="relative"
key={imageName}
userSelect="none"
ref={ref}
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
aspectRatio: '1/1',
}}
>
<IAIDndImage
onClick={handleClick}
imageDTO={imageDTO}
draggableData={draggableData}
isSelected={isSelected}
minSize={0}
onClickReset={handleClickRemove}
isDropDisabled={true}
imageSx={{ w: 'full', h: 'full' }}
isUploadDisabled={true}
resetTooltip="Remove from batch"
withResetIcon
thumbnail
/>
</Box>
)}
</ImageContextMenu>
<Box sx={{ position: 'relative', aspectRatio: '1/1' }}>
<IAIDndImage
imageDTO={imageDTO}
draggableData={draggableData}
isDropDisabled={true}
isUploadDisabled={true}
imageSx={{
w: 'full',
h: 'full',
}}
onClick={handleClick}
isSelected={isSelected}
onClickReset={handleClickRemove}
resetTooltip="Remove from batch"
withResetIcon
thumbnail
/>
</Box>
);
};

View File

@@ -1,7 +1,11 @@
import { Box } from '@chakra-ui/react';
import { AddToBatchDropData } from 'app/components/ImageDnd/typesafeDnd';
import IAIDroppable from 'common/components/IAIDroppable';
import BatchImageGrid from './BatchImageGrid';
import IAIDropOverlay from 'common/components/IAIDropOverlay';
import {
AddToBatchDropData,
isValidDrop,
useDroppable,
} from 'app/components/ImageDnd/typesafeDnd';
const droppableData: AddToBatchDropData = {
id: 'batch',
@@ -9,10 +13,17 @@ const droppableData: AddToBatchDropData = {
};
const BatchImageContainer = () => {
const { isOver, setNodeRef, active } = useDroppable({
id: 'batch-manager',
data: droppableData,
});
return (
<Box position="relative" w="full" h="full">
<Box ref={setNodeRef} position="relative" w="full" h="full">
<BatchImageGrid />
<IAIDroppable data={droppableData} dropLabel="Add to Batch" />
{isValidDrop(droppableData, active) && (
<IAIDropOverlay isOver={isOver} label="Add to Batch" />
)}
</Box>
);
};

View File

@@ -1,4 +1,4 @@
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import { PayloadAction, createAction, createSlice } from '@reduxjs/toolkit';
import { uniq } from 'lodash-es';
import { imageDeleted } from 'services/api/thunks/image';
@@ -26,10 +26,10 @@ const batch = createSlice({
state.isEnabled = action.payload;
},
imageAddedToBatch: (state, action: PayloadAction<string>) => {
state.imageNames.push(action.payload);
state.imageNames = uniq(state.imageNames.concat(action.payload));
},
imagesAddedToBatch: (state, action: PayloadAction<string[]>) => {
state.imageNames = state.imageNames.concat(action.payload);
state.imageNames = uniq(state.imageNames.concat(action.payload));
},
imageRemovedFromBatch: (state, action: PayloadAction<string>) => {
state.imageNames = state.imageNames.filter(
@@ -50,13 +50,10 @@ const batch = createSlice({
batchImageRangeEndSelected: (state, action: PayloadAction<string>) => {
const rangeEndImageName = action.payload;
const lastSelectedImage = state.selection[state.selection.length - 1];
const { imageNames } = state;
const lastClickedIndex = imageNames.findIndex(
const lastClickedIndex = state.imageNames.findIndex(
(n) => n === lastSelectedImage
);
const currentClickedIndex = imageNames.findIndex(
const currentClickedIndex = state.imageNames.findIndex(
(n) => n === rangeEndImageName
);
if (lastClickedIndex > -1 && currentClickedIndex > -1) {
@@ -64,8 +61,7 @@ const batch = createSlice({
const start = Math.min(lastClickedIndex, currentClickedIndex);
const end = Math.max(lastClickedIndex, currentClickedIndex);
const imagesToSelect = imageNames.slice(start, end + 1);
const imagesToSelect = state.imageNames.slice(start, end + 1);
state.selection = uniq(state.selection.concat(imagesToSelect));
}
},
@@ -140,3 +136,7 @@ export const {
} = batch.actions;
export default batch.reducer;
export const selectionAddedToBatch = createAction(
'batch/selectionAddedToBatch'
);

Some files were not shown because too many files have changed in this diff Show More