mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-01-15 11:37:54 -05:00
Compare commits
399 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cfd713b93 | ||
|
|
45f5d7617a | ||
|
|
f49df7d327 | ||
|
|
87ed0ed48a | ||
|
|
d445c88e4c | ||
|
|
c15c43ed2a | ||
|
|
d2f8db9745 | ||
|
|
c1cf01a038 | ||
|
|
2bfb4fc79c | ||
|
|
d037d8f9aa | ||
|
|
d5401e8443 | ||
|
|
d193e4f02a | ||
|
|
ec493e30ee | ||
|
|
081b931edf | ||
|
|
8cd7035494 | ||
|
|
4de6fd3ae6 | ||
|
|
3feb1a6600 | ||
|
|
ea2320c57b | ||
|
|
0ad0016c2d | ||
|
|
c2a3c66e49 | ||
|
|
c0a0d20935 | ||
|
|
028d8d8ead | ||
|
|
657095d2e2 | ||
|
|
1c47dc997e | ||
|
|
a3de6b6165 | ||
|
|
e57f0ff055 | ||
|
|
0362bd5a06 | ||
|
|
feee4c49a2 | ||
|
|
42e052d6f2 | ||
|
|
b03e429b26 | ||
|
|
7399909029 | ||
|
|
c8aaf5e76b | ||
|
|
0cdf7a7048 | ||
|
|
41985487d3 | ||
|
|
41d5a17114 | ||
|
|
14f9d5b6bc | ||
|
|
eec4bdb038 | ||
|
|
f3dd44044a | ||
|
|
61a22eb8cb | ||
|
|
03ca83fe13 | ||
|
|
8f1e25c387 | ||
|
|
29cf4bc002 | ||
|
|
9428642806 | ||
|
|
8620572524 | ||
|
|
f44c7e824d | ||
|
|
c5b8bde285 | ||
|
|
4c86a7ecbf | ||
|
|
b9f9d1c152 | ||
|
|
7567ee2adf | ||
|
|
0e632dbc5c | ||
|
|
49191709a0 | ||
|
|
3af7fc26fa | ||
|
|
a36a627f83 | ||
|
|
b31c71f302 | ||
|
|
5302d4890f | ||
|
|
766b752572 | ||
|
|
7feae5e5ce | ||
|
|
26730ca702 | ||
|
|
1e2c7c51b5 | ||
|
|
da2b6815ac | ||
|
|
68d14de3ee | ||
|
|
38991ffc35 | ||
|
|
f345c0fabc | ||
|
|
ca23b5337e | ||
|
|
35910d3952 | ||
|
|
6f1dcf385b | ||
|
|
84c9ecc83f | ||
|
|
52aa839b7e | ||
|
|
316ed1d478 | ||
|
|
3519e8ae39 | ||
|
|
82f645c7a1 | ||
|
|
cc36cfb617 | ||
|
|
ded8a84284 | ||
|
|
94771ea626 | ||
|
|
51d661023e | ||
|
|
d215829b91 | ||
|
|
fad6c67f01 | ||
|
|
f366640d46 | ||
|
|
36a3fba8cb | ||
|
|
b2ff83092f | ||
|
|
d2db38a5b9 | ||
|
|
fa988a6273 | ||
|
|
149f60946c | ||
|
|
ee9d620a36 | ||
|
|
4e8ce4abab | ||
|
|
d40f2fa37c | ||
|
|
933f4f6857 | ||
|
|
f499b2db7b | ||
|
|
706aaf7460 | ||
|
|
4a706d00bb | ||
|
|
2a8bff601f | ||
|
|
3f0e3192f6 | ||
|
|
c65147e2ff | ||
|
|
1c14e257a3 | ||
|
|
fe24217082 | ||
|
|
aee847065c | ||
|
|
525da3257c | ||
|
|
559654f0ca | ||
|
|
5d33874d58 | ||
|
|
0063315139 | ||
|
|
1cbd609860 | ||
|
|
047c643295 | ||
|
|
d1e03aa1c5 | ||
|
|
1bb8edf57e | ||
|
|
a3e78f0db6 | ||
|
|
1ccf43aa1e | ||
|
|
a290975fae | ||
|
|
43c2116d64 | ||
|
|
9d0a24ead3 | ||
|
|
d61a3d2950 | ||
|
|
7b63858802 | ||
|
|
fae23a744f | ||
|
|
7c574719e5 | ||
|
|
43a212dd47 | ||
|
|
a103bc8a0a | ||
|
|
1a42fbf541 | ||
|
|
d550067dd4 | ||
|
|
7003bcad62 | ||
|
|
ef95f4962c | ||
|
|
2e13bbbe1b | ||
|
|
43349cb5ce | ||
|
|
d037eea42a | ||
|
|
42c5be16d1 | ||
|
|
c7c4453a92 | ||
|
|
c71ddf6e5d | ||
|
|
c33ed68f78 | ||
|
|
48e389f155 | ||
|
|
5c423fece4 | ||
|
|
3f86049802 | ||
|
|
47d395d0a8 | ||
|
|
b666ef41ff | ||
|
|
375f62380b | ||
|
|
42c4462edc | ||
|
|
7591adebd5 | ||
|
|
9d9b2f73db | ||
|
|
abaae39c29 | ||
|
|
b1c9f59c30 | ||
|
|
7bcbe180df | ||
|
|
a626387a0b | ||
|
|
759229e3c8 | ||
|
|
ad4b81ba21 | ||
|
|
637b629b95 | ||
|
|
4aaa807415 | ||
|
|
e884be5042 | ||
|
|
13e129bef2 | ||
|
|
157904522f | ||
|
|
3045cd7b3a | ||
|
|
e9e2bab4ee | ||
|
|
6cd794d860 | ||
|
|
c9b0307bcd | ||
|
|
55aee034b0 | ||
|
|
e81ef0a090 | ||
|
|
1a806739f2 | ||
|
|
067aeeac23 | ||
|
|
47b37d946f | ||
|
|
ddfdeca8bd | ||
|
|
55b2a4388d | ||
|
|
6ab2bebfa6 | ||
|
|
3f18bfed4e | ||
|
|
012054acaa | ||
|
|
efb7f36f28 | ||
|
|
05ea1c7637 | ||
|
|
2ba0f920d2 | ||
|
|
c3ab4f4d6e | ||
|
|
36b3089d5d | ||
|
|
6c4d002bd6 | ||
|
|
b2cfa137a3 | ||
|
|
9d57bc1697 | ||
|
|
e6db36d0c4 | ||
|
|
78832e546a | ||
|
|
6cfeadb33b | ||
|
|
d1d3971ee3 | ||
|
|
e9ce259d43 | ||
|
|
34d988063f | ||
|
|
e2bdbfe721 | ||
|
|
fe7e1958ea | ||
|
|
cf8f18e690 | ||
|
|
da7b31b2a8 | ||
|
|
fb82664944 | ||
|
|
58ae9ed8a5 | ||
|
|
d142a94b67 | ||
|
|
c8135126f2 | ||
|
|
560910ed2f | ||
|
|
b78ac40a22 | ||
|
|
9ecafc8706 | ||
|
|
871cb54988 | ||
|
|
e3069ad336 | ||
|
|
28027702dd | ||
|
|
d72840620a | ||
|
|
4f2de2674e | ||
|
|
340c9c0697 | ||
|
|
f77549dc4f | ||
|
|
5653352ae8 | ||
|
|
f1bc2ea962 | ||
|
|
2a9f7b2e38 | ||
|
|
c379d76844 | ||
|
|
6496fcdcbd | ||
|
|
812b8fddd6 | ||
|
|
dc9165dfc1 | ||
|
|
59826438f6 | ||
|
|
87cd52241d | ||
|
|
7506b0e7ae | ||
|
|
4b29a2f395 | ||
|
|
3bcaa42309 | ||
|
|
8e14cdb8b6 | ||
|
|
9ef6e52ad8 | ||
|
|
148bd70a24 | ||
|
|
1461c88c12 | ||
|
|
bcfeae94d2 | ||
|
|
40eedfebf7 | ||
|
|
d0a231d59e | ||
|
|
4bba7de070 | ||
|
|
e1f2b232c8 | ||
|
|
2c5b0195fc | ||
|
|
56792b2d2c | ||
|
|
d71e8b4980 | ||
|
|
ca50f8193c | ||
|
|
7ee636b68b | ||
|
|
926f69677a | ||
|
|
675ac348de | ||
|
|
62e5b9da18 | ||
|
|
65eabde297 | ||
|
|
6bebd2bfc8 | ||
|
|
cd785ba64b | ||
|
|
726b4637db | ||
|
|
b50241fe6a | ||
|
|
5b8735db3b | ||
|
|
ce286363d0 | ||
|
|
2fa47cf270 | ||
|
|
3446486f40 | ||
|
|
a0cdcdef57 | ||
|
|
abbb3609c8 | ||
|
|
700ad78f87 | ||
|
|
cfb08f326e | ||
|
|
aae4fa3cca | ||
|
|
109adc5a93 | ||
|
|
acb7ef8837 | ||
|
|
3c5e829c72 | ||
|
|
10d9e75391 | ||
|
|
b6a892a673 | ||
|
|
479d5cc362 | ||
|
|
01e4fd100f | ||
|
|
8ecf9fb7e3 | ||
|
|
436d5ee0c6 | ||
|
|
0671fec844 | ||
|
|
80d38c0e47 | ||
|
|
22362350dc | ||
|
|
275d891f48 | ||
|
|
4dbde53f9b | ||
|
|
f6c4682b99 | ||
|
|
b3288ed64e | ||
|
|
f3dfb1b6ea | ||
|
|
65a37ca4ff | ||
|
|
9adbe31fec | ||
|
|
0a2925f02b | ||
|
|
877dcc73c3 | ||
|
|
aec2136323 | ||
|
|
8ef5c54ffe | ||
|
|
6faed4f1ec | ||
|
|
aa71db4d31 | ||
|
|
6407ab4a2e | ||
|
|
a91b0f25cb | ||
|
|
ef664863b5 | ||
|
|
bf8ba1bb37 | ||
|
|
54747bd521 | ||
|
|
d040a6953f | ||
|
|
828497cf89 | ||
|
|
28950a4891 | ||
|
|
1c92838bf9 | ||
|
|
71f6737e19 | ||
|
|
dcac65f46b | ||
|
|
46f549a57a | ||
|
|
fb93101085 | ||
|
|
9aabcfa4b8 | ||
|
|
64587b37db | ||
|
|
c673b6e11d | ||
|
|
a3a49ddda0 | ||
|
|
330a0f0028 | ||
|
|
1104d2a00f | ||
|
|
aed802fa74 | ||
|
|
498d99c828 | ||
|
|
3d19b98208 | ||
|
|
85f5bb4a02 | ||
|
|
269f718d2c | ||
|
|
211bb8a204 | ||
|
|
ef0ef875dd | ||
|
|
9c62648283 | ||
|
|
4ca45f7651 | ||
|
|
2abe2f52f7 | ||
|
|
6f1c814af4 | ||
|
|
1ad6ccc426 | ||
|
|
aedee536a0 | ||
|
|
d2b15fba12 | ||
|
|
a674e781a1 | ||
|
|
0db74f0cde | ||
|
|
d66db67d1a | ||
|
|
2507a7f674 | ||
|
|
145503a0a0 | ||
|
|
32e8dd5647 | ||
|
|
fe87adcb52 | ||
|
|
e95255f6e8 | ||
|
|
efec224523 | ||
|
|
e948e236e7 | ||
|
|
189eb85663 | ||
|
|
94f90f4082 | ||
|
|
1eb491fdaa | ||
|
|
176248a023 | ||
|
|
3c676ed11a | ||
|
|
7a9340b850 | ||
|
|
2c0b474f55 | ||
|
|
74c76611a9 | ||
|
|
1c7176b3f4 | ||
|
|
30363a0018 | ||
|
|
b46dbcc76d | ||
|
|
09879f4e19 | ||
|
|
4daa82c912 | ||
|
|
1cb04d9a4a | ||
|
|
3e6969128c | ||
|
|
e14c490ac6 | ||
|
|
3ef3b97c58 | ||
|
|
3baaefb0cc | ||
|
|
98b0a8ffb2 | ||
|
|
4f85bf078a | ||
|
|
f0563d41db | ||
|
|
a7a71ca935 | ||
|
|
c04822054b | ||
|
|
132e9bebd7 | ||
|
|
0dc45ac903 | ||
|
|
4f9d81917c | ||
|
|
d3c22eceaf | ||
|
|
fb77d271ab | ||
|
|
0371881349 | ||
|
|
4b178fdeca | ||
|
|
b53e36aaaa | ||
|
|
c061cd5e54 | ||
|
|
ddda915ebd | ||
|
|
9a2d8844a2 | ||
|
|
48583df02e | ||
|
|
f9432d10d2 | ||
|
|
0d28cd7ebe | ||
|
|
c9f9a2f2d4 | ||
|
|
a05d10f648 | ||
|
|
14845932fb | ||
|
|
2aa1fc9301 | ||
|
|
98139562f3 | ||
|
|
8365bba5ba | ||
|
|
9f07e83a23 | ||
|
|
1f995d0257 | ||
|
|
6ae2d5ef9d | ||
|
|
55973b4c66 | ||
|
|
d8c6531b70 | ||
|
|
81e385a756 | ||
|
|
f6cb1a455f | ||
|
|
bf60be99dc | ||
|
|
bee0e8248f | ||
|
|
1e658cf9e7 | ||
|
|
f130fa4d66 | ||
|
|
02a47a6806 | ||
|
|
1063498458 | ||
|
|
e9a13ec882 | ||
|
|
bd0765b744 | ||
|
|
6e1388f4fc | ||
|
|
2a9f2b2fe2 | ||
|
|
0a6b0dc3bf | ||
|
|
8753406a6c | ||
|
|
e2b09bed62 | ||
|
|
011910a08c | ||
|
|
bfd70be50b | ||
|
|
9c53bd6a3b | ||
|
|
e479cb5fe4 | ||
|
|
52947f40c3 | ||
|
|
bce9a23b25 | ||
|
|
2d05579568 | ||
|
|
11aabb5693 | ||
|
|
1e1e31d5b7 | ||
|
|
fe86cf6d99 | ||
|
|
cfb63c1b81 | ||
|
|
b44415415a | ||
|
|
9353298b4f | ||
|
|
cf22e09b28 | ||
|
|
6e5ca7ece8 | ||
|
|
b81209e751 | ||
|
|
c4040eb2f0 | ||
|
|
046ea611f9 | ||
|
|
1439da5e88 | ||
|
|
69a504710f | ||
|
|
842b770938 | ||
|
|
ba39331594 | ||
|
|
8ee9509eec | ||
|
|
7b5dcffb3f | ||
|
|
6927e95444 | ||
|
|
76618fee9c | ||
|
|
b51312f1ba | ||
|
|
c2b71854be | ||
|
|
df793c898f | ||
|
|
d6181e4d64 | ||
|
|
0a4ea9ac6f | ||
|
|
9e6f3e9338 | ||
|
|
3848e1926b |
11
.github/workflows/build-container.yml
vendored
11
.github/workflows/build-container.yml
vendored
@@ -76,9 +76,6 @@ jobs:
|
||||
latest=${{ matrix.gpu-driver == 'cuda' && github.ref == 'refs/heads/main' }}
|
||||
suffix=-${{ matrix.gpu-driver }},onlatest=false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
@@ -103,7 +100,7 @@ jobs:
|
||||
push: ${{ github.ref == 'refs/heads/main' || github.ref_type == 'tag' || github.event.inputs.push-to-registry }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
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 }}
|
||||
# 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 }}
|
||||
|
||||
@@ -13,52 +13,67 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
git
|
||||
|
||||
# Install `uv` for package management
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.5.5 /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.6.0 /uv /uvx /bin/
|
||||
|
||||
ENV VIRTUAL_ENV=/opt/venv
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
ENV INVOKEAI_SRC=/opt/invokeai
|
||||
ENV PYTHON_VERSION=3.11
|
||||
ENV UV_PYTHON=3.11
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
ENV UV_LINK_MODE=copy
|
||||
ENV UV_PROJECT_ENVIRONMENT="$VIRTUAL_ENV"
|
||||
ENV UV_INDEX="https://download.pytorch.org/whl/cu124"
|
||||
|
||||
ARG GPU_DRIVER=cuda
|
||||
ARG TARGETPLATFORM="linux/amd64"
|
||||
# unused but available
|
||||
ARG BUILDPLATFORM
|
||||
|
||||
# Switch to the `ubuntu` user to work around dependency issues with uv-installed python
|
||||
RUN mkdir -p ${VIRTUAL_ENV} && \
|
||||
mkdir -p ${INVOKEAI_SRC} && \
|
||||
chmod -R a+w /opt
|
||||
chmod -R a+w /opt && \
|
||||
mkdir ~ubuntu/.cache && chown ubuntu: ~ubuntu/.cache
|
||||
USER ubuntu
|
||||
|
||||
# Install python and create the venv
|
||||
RUN uv python install ${PYTHON_VERSION} && \
|
||||
uv venv --relocatable --prompt "invoke" --python ${PYTHON_VERSION} ${VIRTUAL_ENV}
|
||||
# Install python
|
||||
RUN --mount=type=cache,target=/home/ubuntu/.cache/uv,uid=1000,gid=1000 \
|
||||
uv python install ${PYTHON_VERSION}
|
||||
|
||||
WORKDIR ${INVOKEAI_SRC}
|
||||
COPY invokeai ./invokeai
|
||||
COPY pyproject.toml ./
|
||||
|
||||
# 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}
|
||||
# Install project's dependencies as a separate layer so they aren't rebuilt every commit.
|
||||
# bind-mount instead of copy to defer adding sources to the image until next layer.
|
||||
#
|
||||
# NOTE: there are no pytorch builds for arm64 + cuda, only cpu
|
||||
# x86_64/CUDA is the default
|
||||
RUN --mount=type=cache,target=/home/ubuntu/.cache/uv,uid=1000,gid=1000 \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
--mount=type=bind,source=invokeai/version,target=invokeai/version \
|
||||
if [ "$TARGETPLATFORM" = "linux/arm64" ] || [ "$GPU_DRIVER" = "cpu" ]; then \
|
||||
extra_index_url_arg="--extra-index-url https://download.pytorch.org/whl/cpu"; \
|
||||
UV_INDEX="https://download.pytorch.org/whl/cpu"; \
|
||||
elif [ "$GPU_DRIVER" = "rocm" ]; then \
|
||||
extra_index_url_arg="--extra-index-url https://download.pytorch.org/whl/rocm6.1"; \
|
||||
else \
|
||||
extra_index_url_arg="--extra-index-url https://download.pytorch.org/whl/cu124"; \
|
||||
UV_INDEX="https://download.pytorch.org/whl/rocm6.1"; \
|
||||
fi && \
|
||||
uv pip install --python ${PYTHON_VERSION} $extra_index_url_arg -e "."
|
||||
uv sync --no-install-project
|
||||
|
||||
# Now that the bulk of the dependencies have been installed, copy in the project files that change more frequently.
|
||||
COPY invokeai invokeai
|
||||
COPY pyproject.toml .
|
||||
|
||||
RUN --mount=type=cache,target=/home/ubuntu/.cache/uv,uid=1000,gid=1000 \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
if [ "$TARGETPLATFORM" = "linux/arm64" ] || [ "$GPU_DRIVER" = "cpu" ]; then \
|
||||
UV_INDEX="https://download.pytorch.org/whl/cpu"; \
|
||||
elif [ "$GPU_DRIVER" = "rocm" ]; then \
|
||||
UV_INDEX="https://download.pytorch.org/whl/rocm6.1"; \
|
||||
fi && \
|
||||
uv sync
|
||||
|
||||
|
||||
#### Build the Web UI ------------------------------------
|
||||
|
||||
FROM node:20-slim AS web-builder
|
||||
FROM docker.io/node:22-slim AS web-builder
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack use pnpm@8.x
|
||||
@@ -98,6 +113,7 @@ RUN apt update && apt install -y --no-install-recommends \
|
||||
|
||||
ENV INVOKEAI_SRC=/opt/invokeai
|
||||
ENV VIRTUAL_ENV=/opt/venv
|
||||
ENV UV_PROJECT_ENVIRONMENT="$VIRTUAL_ENV"
|
||||
ENV PYTHON_VERSION=3.11
|
||||
ENV INVOKEAI_ROOT=/invokeai
|
||||
ENV INVOKEAI_HOST=0.0.0.0
|
||||
@@ -109,7 +125,7 @@ ENV CONTAINER_GID=${CONTAINER_GID:-1000}
|
||||
# Install `uv` for package management
|
||||
# and install python for the ubuntu user (expected to exist on ubuntu >=24.x)
|
||||
# this is too tiny to optimize with multi-stage builds, but maybe we'll come back to it
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.5.5 /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.6.0 /uv /uvx /bin/
|
||||
USER ubuntu
|
||||
RUN uv python install ${PYTHON_VERSION}
|
||||
USER root
|
||||
|
||||
138
docs/RELEASE.md
138
docs/RELEASE.md
@@ -1,41 +1,50 @@
|
||||
# Release Process
|
||||
|
||||
The app is published in twice, in different build formats.
|
||||
The Invoke application is published as a python package on [PyPI]. This includes both a source distribution and built distribution (a wheel).
|
||||
|
||||
- A [PyPI] distribution. This includes both a source distribution and built distribution (a wheel). Users install with `pip install invokeai`. The updater uses this build.
|
||||
- An installer on the [InvokeAI Releases Page]. This is a zip file with install scripts and a wheel. This is only used for new installs.
|
||||
Most users install it with the [Launcher](https://github.com/invoke-ai/launcher/), others with `pip`.
|
||||
|
||||
The launcher uses GitHub as the source of truth for available releases.
|
||||
|
||||
## Broad Strokes
|
||||
|
||||
- Merge all changes and bump the version in the codebase.
|
||||
- Tag the release commit.
|
||||
- Wait for the release workflow to complete.
|
||||
- Approve the PyPI publish jobs.
|
||||
- Write GH release notes.
|
||||
|
||||
## General Prep
|
||||
|
||||
Make a developer call-out for PRs to merge. Merge and test things out.
|
||||
|
||||
While the release workflow does not include end-to-end tests, it does pause before publishing so you can download and test the final build.
|
||||
Make a developer call-out for PRs to merge. Merge and test things out. Bump the version by editing `invokeai/version/invokeai_version.py`.
|
||||
|
||||
## Release Workflow
|
||||
|
||||
The `release.yml` workflow runs a number of jobs to handle code checks, tests, build and publish on PyPI.
|
||||
|
||||
It is triggered on **tag push**, when the tag matches `v*`. It doesn't matter if you've prepped a release branch like `release/v3.5.0` or are releasing from `main` - it works the same.
|
||||
|
||||
> Because commits are reference-counted, it is safe to create a release branch, tag it, let the workflow run, then delete the branch. So long as the tag exists, that commit will exist.
|
||||
It is triggered on **tag push**, when the tag matches `v*`.
|
||||
|
||||
### Triggering the Workflow
|
||||
|
||||
Run `make tag-release` to tag the current commit and kick off the workflow.
|
||||
Ensure all commits that should be in the release are merged, and you have pulled them locally.
|
||||
|
||||
The release may also be dispatched [manually].
|
||||
Double-check that you have checked out the commit that will represent the release (typically the latest commit on `main`).
|
||||
|
||||
Run `make tag-release` to tag the current commit and kick off the workflow. You will be prompted to provide a message - use the version specifier.
|
||||
|
||||
If this version's tag already exists for some reason (maybe you had to make a last minute change), the script will overwrite it.
|
||||
|
||||
> In case you cannot use the Make target, the release may also be dispatched [manually] via GH.
|
||||
|
||||
### Workflow Jobs and Process
|
||||
|
||||
The workflow consists of a number of concurrently-run jobs, and two final publish jobs.
|
||||
The workflow consists of a number of concurrently-run checks and tests, then two final publish jobs.
|
||||
|
||||
The publish jobs require manual approval and are only run if the other jobs succeed.
|
||||
|
||||
#### `check-version` Job
|
||||
|
||||
This job checks that the git ref matches the app version. It matches the ref against the `__version__` variable in `invokeai/version/invokeai_version.py`.
|
||||
|
||||
When the workflow is triggered by tag push, the ref is the tag. If the workflow is run manually, the ref is the target selected from the **Use workflow from** dropdown.
|
||||
This job ensures that the `invokeai` python package version specifier matches the tag for the release. The version specifier is pulled from the `__version__` variable in `invokeai/version/invokeai_version.py`.
|
||||
|
||||
This job uses [samuelcolvin/check-python-version].
|
||||
|
||||
@@ -43,62 +52,52 @@ This job uses [samuelcolvin/check-python-version].
|
||||
|
||||
#### Check and Test Jobs
|
||||
|
||||
Next, these jobs run and must pass. They are the same jobs that are run for every PR.
|
||||
|
||||
- **`python-tests`**: runs `pytest` on matrix of platforms
|
||||
- **`python-checks`**: runs `ruff` (format and lint)
|
||||
- **`frontend-tests`**: runs `vitest`
|
||||
- **`frontend-checks`**: runs `prettier` (format), `eslint` (lint), `dpdm` (circular refs), `tsc` (static type check) and `knip` (unused imports)
|
||||
|
||||
> **TODO** We should add `mypy` or `pyright` to the **`check-python`** job.
|
||||
|
||||
> **TODO** We should add an end-to-end test job that generates an image.
|
||||
- **`typegen-checks`**: ensures the frontend and backend types are synced
|
||||
|
||||
#### `build-installer` Job
|
||||
|
||||
This sets up both python and frontend dependencies and builds the python package. Internally, this runs `installer/create_installer.sh` and uploads two artifacts:
|
||||
|
||||
- **`dist`**: the python distribution, to be published on PyPI
|
||||
- **`InvokeAI-installer-${VERSION}.zip`**: the installer to be included in the GitHub release
|
||||
- **`InvokeAI-installer-${VERSION}.zip`**: the legacy install scripts
|
||||
|
||||
You don't need to download either of these files.
|
||||
|
||||
> The legacy install scripts are no longer used, but we haven't updated the workflow to skip building them.
|
||||
|
||||
#### Sanity Check & Smoke Test
|
||||
|
||||
At this point, the release workflow pauses as the remaining publish jobs require approval. Time to test the installer.
|
||||
At this point, the release workflow pauses as the remaining publish jobs require approval.
|
||||
|
||||
Because the installer pulls from PyPI, and we haven't published to PyPI yet, you will need to install from the wheel:
|
||||
It's possible to test the python package before it gets published to PyPI. We've never had problems with it, so it's not necessary to do this.
|
||||
|
||||
- Download and unzip `dist.zip` and the installer from the **Summary** tab of the workflow
|
||||
- Run the installer script using the `--wheel` CLI arg, pointing at the wheel:
|
||||
But, if you want to be extra-super careful, here's how to test it:
|
||||
|
||||
```sh
|
||||
./install.sh --wheel ../InvokeAI-4.0.0rc6-py3-none-any.whl
|
||||
```
|
||||
|
||||
- Install to a temporary directory so you get the new user experience
|
||||
- Download a model and generate
|
||||
|
||||
> The same wheel file is bundled in the installer and in the `dist` artifact, which is uploaded to PyPI. You should end up with the exactly the same installation as if the installer got the wheel from PyPI.
|
||||
- Download the `dist.zip` build artifact from the `build-installer` job
|
||||
- Unzip it and find the wheel file
|
||||
- Create a fresh Invoke install by following the [manual install guide](https://invoke-ai.github.io/InvokeAI/installation/manual/) - but instead of installing from PyPI, install from the wheel
|
||||
- Test the app
|
||||
|
||||
##### Something isn't right
|
||||
|
||||
If testing reveals any issues, no worries. Cancel the workflow, which will cancel the pending publish jobs (you didn't approve them prematurely, right?).
|
||||
|
||||
Now you can start from the top:
|
||||
|
||||
- Fix the issues and PR the fixes per usual
|
||||
- Get the PR approved and merged per usual
|
||||
- Switch to `main` and pull in the fixes
|
||||
- Run `make tag-release` to move the tag to `HEAD` (which has the fixes) and kick off the release workflow again
|
||||
- Re-do the sanity check
|
||||
If testing reveals any issues, no worries. Cancel the workflow, which will cancel the pending publish jobs (you didn't approve them prematurely, right?) and start over.
|
||||
|
||||
#### PyPI Publish Jobs
|
||||
|
||||
The publish jobs will run if any of the previous jobs fail.
|
||||
The publish jobs will not run if any of the previous jobs fail.
|
||||
|
||||
They use [GitHub environments], which are configured as [trusted publishers] on PyPI.
|
||||
|
||||
Both jobs require a maintainer to approve them from the workflow's **Summary** tab.
|
||||
Both jobs require a @hipsterusername or @psychedelicious to approve them from the workflow's **Summary** tab.
|
||||
|
||||
- Click the **Review deployments** button
|
||||
- Select the environment (either `testpypi` or `pypi`)
|
||||
- Select the environment (either `testpypi` or `pypi` - typically you select both)
|
||||
- Click **Approve and deploy**
|
||||
|
||||
> **If the version already exists on PyPI, the publish jobs will fail.** PyPI only allows a given version to be published once - you cannot change it. If version published on PyPI has a problem, you'll need to "fail forward" by bumping the app version and publishing a followup release.
|
||||
@@ -113,46 +112,33 @@ If there are no incidents, contact @hipsterusername or @lstein, who have owner a
|
||||
|
||||
Publishes the distribution on the [Test PyPI] index, using the `testpypi` GitHub environment.
|
||||
|
||||
This job is not required for the production PyPI publish, but included just in case you want to test the PyPI release.
|
||||
This job is not required for the production PyPI publish, but included just in case you want to test the PyPI release for some reason:
|
||||
|
||||
If approved and successful, you could try out the test release like this:
|
||||
|
||||
```sh
|
||||
# Create a new virtual environment
|
||||
python -m venv ~/.test-invokeai-dist --prompt test-invokeai-dist
|
||||
# Install the distribution from Test PyPI
|
||||
pip install --index-url https://test.pypi.org/simple/ invokeai
|
||||
# Run and test the app
|
||||
invokeai-web
|
||||
# Cleanup
|
||||
deactivate
|
||||
rm -rf ~/.test-invokeai-dist
|
||||
```
|
||||
- Approve this publish job without approving the prod publish
|
||||
- Let it finish
|
||||
- Create a fresh Invoke install by following the [manual install guide](https://invoke-ai.github.io/InvokeAI/installation/manual/), making sure to use the Test PyPI index URL: `https://test.pypi.org/simple/`
|
||||
- Test the app
|
||||
|
||||
#### `publish-pypi` Job
|
||||
|
||||
Publishes the distribution on the production PyPI index, using the `pypi` GitHub environment.
|
||||
|
||||
## Publish the GitHub Release with installer
|
||||
It's a good idea to wait to approve and run this job until you have the release notes ready!
|
||||
|
||||
Once the release is published to PyPI, it's time to publish the GitHub release.
|
||||
## Prep and publish the GitHub Release
|
||||
|
||||
1. [Draft a new release] on GitHub, choosing the tag that triggered the release.
|
||||
1. Write the release notes, describing important changes. The **Generate release notes** button automatically inserts the changelog and new contributors, and you can copy/paste the intro from previous releases.
|
||||
1. Use `scripts/get_external_contributions.py` to get a list of external contributions to shout out in the release notes.
|
||||
1. Upload the zip file created in **`build`** job into the Assets section of the release notes.
|
||||
1. Check **Set as a pre-release** if it's a pre-release.
|
||||
1. Check **Create a discussion for this release**.
|
||||
1. Publish the release.
|
||||
1. Announce the release in Discord.
|
||||
|
||||
> **TODO** Workflows can create a GitHub release from a template and upload release assets. One popular action to handle this is [ncipollo/release-action]. A future enhancement to the release process could set this up.
|
||||
|
||||
## Manual Build
|
||||
|
||||
The `build installer` workflow can be dispatched manually. This is useful to test the installer for a given branch or tag.
|
||||
|
||||
No checks are run, it just builds.
|
||||
2. The **Generate release notes** button automatically inserts the changelog and new contributors. Make sure to select the correct tags for this release and the last stable release. GH often selects the wrong tags - do this manually.
|
||||
3. Write the release notes, describing important changes. Contributions from community members should be shouted out. Use the GH-generated changelog to see all contributors. If there are Weblate translation updates, open that PR and shout out every person who contributed a translation.
|
||||
4. Check **Set as a pre-release** if it's a pre-release.
|
||||
5. Approve and wait for the `publish-pypi` job to finish if you haven't already.
|
||||
6. Publish the GH release.
|
||||
7. Post the release in Discord in the [releases](https://discord.com/channels/1020123559063990373/1149260708098359327) channel with abbreviated notes. For example:
|
||||
> Invoke v5.7.0 (stable): <https://github.com/invoke-ai/InvokeAI/releases/tag/v5.7.0>
|
||||
>
|
||||
> It's a pretty big one - Form Builder, Metadata Nodes (thanks @SkunkWorxDark!), and much more.
|
||||
8. Right click the message in releases and copy the link to it. Then, post that link in the [new-release-discussion](https://discord.com/channels/1020123559063990373/1149506274971631688) channel. For example:
|
||||
> Invoke v5.7.0 (stable): <https://discord.com/channels/1020123559063990373/1149260708098359327/1344521744916021248>
|
||||
|
||||
## Manual Release
|
||||
|
||||
@@ -160,12 +146,10 @@ The `release` workflow can be dispatched manually. You must dispatch the workflo
|
||||
|
||||
This functionality is available as a fallback in case something goes wonky. Typically, releases should be triggered via tag push as described above.
|
||||
|
||||
[InvokeAI Releases Page]: https://github.com/invoke-ai/InvokeAI/releases
|
||||
[PyPI]: https://pypi.org/
|
||||
[Draft a new release]: https://github.com/invoke-ai/InvokeAI/releases/new
|
||||
[Test PyPI]: https://test.pypi.org/
|
||||
[version specifier]: https://packaging.python.org/en/latest/specifications/version-specifiers/
|
||||
[ncipollo/release-action]: https://github.com/ncipollo/release-action
|
||||
[GitHub environments]: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment
|
||||
[trusted publishers]: https://docs.pypi.org/trusted-publishers/
|
||||
[samuelcolvin/check-python-version]: https://github.com/samuelcolvin/check-python-version
|
||||
|
||||
@@ -31,6 +31,7 @@ It is possible to fine-tune the settings for best performance or if you still ge
|
||||
Low-VRAM mode involves 4 features, each of which can be configured or fine-tuned:
|
||||
|
||||
- Partial model loading (`enable_partial_loading`)
|
||||
- PyTorch CUDA allocator config (`pytorch_cuda_alloc_conf`)
|
||||
- Dynamic RAM and VRAM cache sizes (`max_cache_ram_gb`, `max_cache_vram_gb`)
|
||||
- Working memory (`device_working_mem_gb`)
|
||||
- Keeping a RAM weight copy (`keep_ram_copy_of_weights`)
|
||||
@@ -51,6 +52,16 @@ As described above, you can enable partial model loading by adding this line to
|
||||
enable_partial_loading: true
|
||||
```
|
||||
|
||||
### PyTorch CUDA allocator config
|
||||
|
||||
The PyTorch CUDA allocator's behavior can be configured using the `pytorch_cuda_alloc_conf` config. Tuning the allocator configuration can help to reduce the peak reserved VRAM. The optimal configuration is dependent on many factors (e.g. device type, VRAM, CUDA driver version, etc.), but switching from PyTorch's native allocator to using CUDA's built-in allocator works well on many systems. To try this, add the following line to your `invokeai.yaml` file:
|
||||
|
||||
```yaml
|
||||
pytorch_cuda_alloc_conf: "backend:cudaMallocAsync"
|
||||
```
|
||||
|
||||
A more complete explanation of the available configuration options is [here](https://pytorch.org/docs/stable/notes/cuda.html#optimizing-memory-usage-with-pytorch-cuda-alloc-conf).
|
||||
|
||||
### Dynamic RAM and VRAM cache sizes
|
||||
|
||||
Loading models from disk is slow and can be a major bottleneck for performance. Invoke uses two model caches - RAM and VRAM - to reduce loading from disk to a minimum.
|
||||
@@ -75,24 +86,26 @@ But, if your GPU has enough VRAM to hold models fully, you might get a perf boos
|
||||
# As an example, if your system has 32GB of RAM and no other heavy processes, setting the `max_cache_ram_gb` to 28GB
|
||||
# might be a good value to achieve aggressive model caching.
|
||||
max_cache_ram_gb: 28
|
||||
|
||||
# The default max cache VRAM size is adjusted dynamically based on the amount of available VRAM (taking into
|
||||
# consideration the VRAM used by other processes).
|
||||
# You can override the default value by setting `max_cache_vram_gb`. Note that this value takes precedence over the
|
||||
# `device_working_mem_gb`.
|
||||
# It is recommended to set the VRAM cache size to be as large as possible while leaving enough room for the working
|
||||
# memory of the tasks you will be doing. For example, on a 24GB GPU that will be running unquantized FLUX without any
|
||||
# auxiliary models, 18GB might be a good value.
|
||||
max_cache_vram_gb: 18
|
||||
# You can override the default value by setting `max_cache_vram_gb`.
|
||||
# CAUTION: Most users should not manually set this value. See warning below.
|
||||
max_cache_vram_gb: 16
|
||||
```
|
||||
|
||||
!!! tip "Max safe value for `max_cache_vram_gb`"
|
||||
!!! warning "Max safe value for `max_cache_vram_gb`"
|
||||
|
||||
To determine the max safe value for `max_cache_vram_gb`, subtract `device_working_mem_gb` from your GPU's VRAM. As described below, the default for `device_working_mem_gb` is 3GB.
|
||||
Most users should not manually configure the `max_cache_vram_gb`. This configuration value takes precedence over the `device_working_mem_gb` and any operations that explicitly reserve additional working memory (e.g. VAE decode). As such, manually configuring it increases the likelihood of encountering out-of-memory errors.
|
||||
|
||||
For users who wish to configure `max_cache_vram_gb`, the max safe value can be determined by subtracting `device_working_mem_gb` from your GPU's VRAM. As described below, the default for `device_working_mem_gb` is 3GB.
|
||||
|
||||
For example, if you have a 12GB GPU, the max safe value for `max_cache_vram_gb` is `12GB - 3GB = 9GB`.
|
||||
|
||||
If you had increased `device_working_mem_gb` to 4GB, then the max safe value for `max_cache_vram_gb` is `12GB - 4GB = 8GB`.
|
||||
|
||||
Most users who override `max_cache_vram_gb` are doing so because they wish to use significantly less VRAM, and should be setting `max_cache_vram_gb` to a value significantly less than the 'max safe value'.
|
||||
|
||||
### Working memory
|
||||
|
||||
Invoke cannot use _all_ of your VRAM for model caching and loading. It requires some VRAM to use as working memory for various operations.
|
||||
|
||||
@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy
|
||||
from invokeai.app.services.boards.boards_common import BoardDTO
|
||||
from invokeai.app.services.image_records.image_records_common import ImageCategory
|
||||
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
|
||||
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
|
||||
|
||||
@@ -87,7 +88,9 @@ async def delete_board(
|
||||
try:
|
||||
if include_images is True:
|
||||
deleted_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
|
||||
board_id=board_id
|
||||
board_id=board_id,
|
||||
categories=None,
|
||||
is_intermediate=None,
|
||||
)
|
||||
ApiDependencies.invoker.services.images.delete_images_on_board(board_id=board_id)
|
||||
ApiDependencies.invoker.services.boards.delete(board_id=board_id)
|
||||
@@ -98,7 +101,9 @@ async def delete_board(
|
||||
)
|
||||
else:
|
||||
deleted_board_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
|
||||
board_id=board_id
|
||||
board_id=board_id,
|
||||
categories=None,
|
||||
is_intermediate=None,
|
||||
)
|
||||
ApiDependencies.invoker.services.boards.delete(board_id=board_id)
|
||||
return DeleteBoardResult(
|
||||
@@ -142,10 +147,14 @@ async def list_boards(
|
||||
)
|
||||
async def list_all_board_image_names(
|
||||
board_id: str = Path(description="The id of the board"),
|
||||
categories: list[ImageCategory] | None = Query(default=None, description="The categories of image to include."),
|
||||
is_intermediate: bool | None = Query(default=None, description="Whether to list intermediate images."),
|
||||
) -> list[str]:
|
||||
"""Gets a list of images for a board"""
|
||||
|
||||
image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
|
||||
board_id,
|
||||
categories,
|
||||
is_intermediate,
|
||||
)
|
||||
return image_names
|
||||
|
||||
@@ -16,6 +16,7 @@ from invokeai.app.services.session_queue.session_queue_common import (
|
||||
ClearResult,
|
||||
EnqueueBatchResult,
|
||||
PruneResult,
|
||||
RetryItemsResult,
|
||||
SessionQueueCountsByDestination,
|
||||
SessionQueueItem,
|
||||
SessionQueueItemDTO,
|
||||
@@ -47,7 +48,9 @@ async def enqueue_batch(
|
||||
) -> EnqueueBatchResult:
|
||||
"""Processes a batch and enqueues the output graphs for execution."""
|
||||
|
||||
return ApiDependencies.invoker.services.session_queue.enqueue_batch(queue_id=queue_id, batch=batch, prepend=prepend)
|
||||
return await ApiDependencies.invoker.services.session_queue.enqueue_batch(
|
||||
queue_id=queue_id, batch=batch, prepend=prepend
|
||||
)
|
||||
|
||||
|
||||
@session_queue_router.get(
|
||||
@@ -135,6 +138,19 @@ async def cancel_by_destination(
|
||||
)
|
||||
|
||||
|
||||
@session_queue_router.put(
|
||||
"/{queue_id}/retry_items_by_id",
|
||||
operation_id="retry_items_by_id",
|
||||
responses={200: {"model": RetryItemsResult}},
|
||||
)
|
||||
async def retry_items_by_id(
|
||||
queue_id: str = Path(description="The queue id to perform this operation on"),
|
||||
item_ids: list[int] = Body(description="The queue item ids to retry"),
|
||||
) -> RetryItemsResult:
|
||||
"""Immediately cancels all queue items with the given origin"""
|
||||
return ApiDependencies.invoker.services.session_queue.retry_items_by_id(queue_id=queue_id, item_ids=item_ids)
|
||||
|
||||
|
||||
@session_queue_router.put(
|
||||
"/{queue_id}/clear",
|
||||
operation_id="clear",
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import mimetypes
|
||||
import socket
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
@@ -15,11 +11,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi_events.handlers.local import local_handler
|
||||
from fastapi_events.middleware import EventHandlerASGIMiddleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from torch.backends.mps import is_available as is_mps_available
|
||||
|
||||
# for PyCharm:
|
||||
# noinspection PyUnresolvedReferences
|
||||
import invokeai.backend.util.hotfixes # noqa: F401 (monkeypatching on import)
|
||||
import invokeai.frontend.web as web_dir
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.api.no_cache_staticfiles import NoCacheStaticFiles
|
||||
@@ -38,31 +30,13 @@ from invokeai.app.api.routers import (
|
||||
from invokeai.app.api.sockets import SocketIO
|
||||
from invokeai.app.services.config.config_default import get_config
|
||||
from invokeai.app.util.custom_openapi import get_openapi_func
|
||||
from invokeai.backend.util.devices import TorchDevice
|
||||
from invokeai.backend.util.logging import InvokeAILogger
|
||||
|
||||
app_config = get_config()
|
||||
|
||||
|
||||
if is_mps_available():
|
||||
import invokeai.backend.util.mps_fixes # noqa: F401 (monkeypatching on import)
|
||||
|
||||
|
||||
logger = InvokeAILogger.get_logger(config=app_config)
|
||||
# fix for windows mimetypes registry entries being borked
|
||||
# see https://github.com/invoke-ai/InvokeAI/discussions/3684#discussioncomment-6391352
|
||||
mimetypes.add_type("application/javascript", ".js")
|
||||
mimetypes.add_type("text/css", ".css")
|
||||
|
||||
torch_device_name = TorchDevice.get_torch_device_name()
|
||||
logger.info(f"Using torch device: {torch_device_name}")
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
|
||||
# We may change the port if the default is in use, this global variable is used to store the port so that we can log
|
||||
# the correct port when the server starts in the lifespan handler.
|
||||
port = app_config.port
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -71,7 +45,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Log the server address when it starts - in case the network log level is not high enough to see the startup log
|
||||
proto = "https" if app_config.ssl_certfile else "http"
|
||||
msg = f"Invoke running on {proto}://{app_config.host}:{port} (Press CTRL+C to quit)"
|
||||
msg = f"Invoke running on {proto}://{app_config.host}:{app_config.port} (Press CTRL+C to quit)"
|
||||
|
||||
# Logging this way ignores the logger's log level and _always_ logs the message
|
||||
record = logger.makeRecord(
|
||||
@@ -186,73 +160,3 @@ except RuntimeError:
|
||||
app.mount(
|
||||
"/static", NoCacheStaticFiles(directory=Path(web_root_path, "static/")), name="static"
|
||||
) # docs favicon is in here
|
||||
|
||||
|
||||
def check_cudnn(logger: logging.Logger) -> None:
|
||||
"""Check for cuDNN issues that could be causing degraded performance."""
|
||||
if torch.backends.cudnn.is_available():
|
||||
try:
|
||||
# Note: At the time of writing (torch 2.2.1), torch.backends.cudnn.version() only raises an error the first
|
||||
# time it is called. Subsequent calls will return the version number without complaining about a mismatch.
|
||||
cudnn_version = torch.backends.cudnn.version()
|
||||
logger.info(f"cuDNN version: {cudnn_version}")
|
||||
except RuntimeError as e:
|
||||
logger.warning(
|
||||
"Encountered a cuDNN version issue. This may result in degraded performance. This issue is usually "
|
||||
"caused by an incompatible cuDNN version installed in your python environment, or on the host "
|
||||
f"system. Full error message:\n{e}"
|
||||
)
|
||||
|
||||
|
||||
def invoke_api() -> None:
|
||||
def find_port(port: int) -> int:
|
||||
"""Find a port not in use starting at given port"""
|
||||
# Taken from https://waylonwalker.com/python-find-available-port/, thanks Waylon!
|
||||
# https://github.com/WaylonWalker
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(1)
|
||||
if s.connect_ex(("localhost", port)) == 0:
|
||||
return find_port(port=port + 1)
|
||||
else:
|
||||
return port
|
||||
|
||||
if app_config.dev_reload:
|
||||
try:
|
||||
import jurigged
|
||||
except ImportError as e:
|
||||
logger.error(
|
||||
'Can\'t start `--dev_reload` because jurigged is not found; `pip install -e ".[dev]"` to include development dependencies.',
|
||||
exc_info=e,
|
||||
)
|
||||
else:
|
||||
jurigged.watch(logger=InvokeAILogger.get_logger(name="jurigged").info)
|
||||
|
||||
global port
|
||||
port = find_port(app_config.port)
|
||||
if port != app_config.port:
|
||||
logger.warn(f"Port {app_config.port} in use, using port {port}")
|
||||
|
||||
check_cudnn(logger)
|
||||
|
||||
config = uvicorn.Config(
|
||||
app=app,
|
||||
host=app_config.host,
|
||||
port=port,
|
||||
loop="asyncio",
|
||||
log_level=app_config.log_level_network,
|
||||
ssl_certfile=app_config.ssl_certfile,
|
||||
ssl_keyfile=app_config.ssl_keyfile,
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
# replace uvicorn's loggers with InvokeAI's for consistent appearance
|
||||
uvicorn_logger = InvokeAILogger.get_logger("uvicorn")
|
||||
uvicorn_logger.handlers.clear()
|
||||
for hdlr in logger.handlers:
|
||||
uvicorn_logger.addHandler(hdlr)
|
||||
|
||||
loop.run_until_complete(server.serve())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
invoke_api()
|
||||
|
||||
@@ -1,33 +1,5 @@
|
||||
import shutil
|
||||
import sys
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
||||
from invokeai.app.services.config.config_default import get_config
|
||||
|
||||
custom_nodes_path = Path(get_config().custom_nodes_path)
|
||||
custom_nodes_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
custom_nodes_init_path = str(custom_nodes_path / "__init__.py")
|
||||
custom_nodes_readme_path = str(custom_nodes_path / "README.md")
|
||||
|
||||
# copy our custom nodes __init__.py to the custom nodes directory
|
||||
shutil.copy(Path(__file__).parent / "custom_nodes/init.py", custom_nodes_init_path)
|
||||
shutil.copy(Path(__file__).parent / "custom_nodes/README.md", custom_nodes_readme_path)
|
||||
|
||||
# set the same permissions as the destination directory, in case our source is read-only,
|
||||
# so that the files are user-writable
|
||||
for p in custom_nodes_path.glob("**/*"):
|
||||
p.chmod(custom_nodes_path.stat().st_mode)
|
||||
|
||||
# Import custom nodes, see https://docs.python.org/3/library/importlib.html#importing-programmatically
|
||||
spec = spec_from_file_location("custom_nodes", custom_nodes_init_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Could not load custom nodes from {custom_nodes_init_path}")
|
||||
module = module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# add core nodes to __all__
|
||||
python_files = filter(lambda f: not f.name.startswith("_"), Path(__file__).parent.glob("*.py"))
|
||||
__all__ = [f.stem for f in python_files] # type: ignore
|
||||
|
||||
@@ -44,8 +44,6 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = InvokeAILogger.get_logger()
|
||||
|
||||
CUSTOM_NODE_PACK_SUFFIX = "__invokeai-custom-node"
|
||||
|
||||
|
||||
class InvalidVersionError(ValueError):
|
||||
pass
|
||||
@@ -240,6 +238,11 @@ class BaseInvocation(ABC, BaseModel):
|
||||
"""Gets the invocation's output annotation (i.e. the return annotation of its `invoke()` method)."""
|
||||
return signature(cls.invoke).return_annotation
|
||||
|
||||
@classmethod
|
||||
def get_invocation_for_type(cls, invocation_type: str) -> BaseInvocation | None:
|
||||
"""Gets the invocation class for a given invocation type."""
|
||||
return cls.get_invocations_map().get(invocation_type)
|
||||
|
||||
@staticmethod
|
||||
def json_schema_extra(schema: dict[str, Any], model_class: Type[BaseInvocation]) -> None:
|
||||
"""Adds various UI-facing attributes to the invocation's OpenAPI schema."""
|
||||
@@ -446,8 +449,27 @@ def invocation(
|
||||
if re.compile(r"^\S+$").match(invocation_type) is None:
|
||||
raise ValueError(f'"invocation_type" must consist of non-whitespace characters, got "{invocation_type}"')
|
||||
|
||||
# The node pack is the module name - will be "invokeai" for built-in nodes
|
||||
node_pack = cls.__module__.split(".")[0]
|
||||
|
||||
# Handle the case where an existing node is being clobbered by the one we are registering
|
||||
if invocation_type in BaseInvocation.get_invocation_types():
|
||||
raise ValueError(f'Invocation type "{invocation_type}" already exists')
|
||||
clobbered_invocation = BaseInvocation.get_invocation_for_type(invocation_type)
|
||||
# This should always be true - we just checked if the invocation type was in the set
|
||||
assert clobbered_invocation is not None
|
||||
|
||||
clobbered_node_pack = clobbered_invocation.UIConfig.node_pack
|
||||
|
||||
if clobbered_node_pack == "invokeai":
|
||||
# The node being clobbered is a core node
|
||||
raise ValueError(
|
||||
f'Cannot load node "{invocation_type}" from node pack "{node_pack}" - a core node with the same type already exists'
|
||||
)
|
||||
else:
|
||||
# The node being clobbered is a custom node
|
||||
raise ValueError(
|
||||
f'Cannot load node "{invocation_type}" from node pack "{node_pack}" - a node with the same type already exists in node pack "{clobbered_node_pack}"'
|
||||
)
|
||||
|
||||
validate_fields(cls.model_fields, invocation_type)
|
||||
|
||||
@@ -457,8 +479,7 @@ def invocation(
|
||||
uiconfig["tags"] = tags
|
||||
uiconfig["category"] = category
|
||||
uiconfig["classification"] = classification
|
||||
# The node pack is the module name - will be "invokeai" for built-in nodes
|
||||
uiconfig["node_pack"] = cls.__module__.split(".")[0]
|
||||
uiconfig["node_pack"] = node_pack
|
||||
|
||||
if version is not None:
|
||||
try:
|
||||
|
||||
@@ -64,13 +64,50 @@ class ImageBatchInvocation(BaseBatchInvocation):
|
||||
"""Create a batched generation, where the workflow is executed once for each image in the batch."""
|
||||
|
||||
images: list[ImageField] = InputField(
|
||||
default=[], min_length=1, description="The images to batch over", input=Input.Direct
|
||||
default=[],
|
||||
min_length=1,
|
||||
description="The images to batch over",
|
||||
)
|
||||
|
||||
def invoke(self, context: InvocationContext) -> ImageOutput:
|
||||
raise NotExecutableNodeError()
|
||||
|
||||
|
||||
@invocation_output("image_generator_output")
|
||||
class ImageGeneratorOutput(BaseInvocationOutput):
|
||||
"""Base class for nodes that output a collection of boards"""
|
||||
|
||||
images: list[ImageField] = OutputField(description="The generated images")
|
||||
|
||||
|
||||
class ImageGeneratorField(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
@invocation(
|
||||
"image_generator",
|
||||
title="Image Generator",
|
||||
tags=["primitives", "board", "image", "batch", "special"],
|
||||
category="primitives",
|
||||
version="1.0.0",
|
||||
classification=Classification.Special,
|
||||
)
|
||||
class ImageGenerator(BaseInvocation):
|
||||
"""Generated a collection of images for use in a batched generation"""
|
||||
|
||||
generator: ImageGeneratorField = InputField(
|
||||
description="The image generator.",
|
||||
input=Input.Direct,
|
||||
title="Generator Type",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
raise NotExecutableNodeError()
|
||||
|
||||
def invoke(self, context: InvocationContext) -> ImageGeneratorOutput:
|
||||
raise NotExecutableNodeError()
|
||||
|
||||
|
||||
@invocation(
|
||||
"string_batch",
|
||||
title="String Batch",
|
||||
|
||||
@@ -10,10 +10,12 @@ from pathlib import Path
|
||||
from invokeai.backend.util.logging import InvokeAILogger
|
||||
|
||||
logger = InvokeAILogger.get_logger()
|
||||
loaded_count = 0
|
||||
loaded_packs: list[str] = []
|
||||
failed_packs: list[str] = []
|
||||
|
||||
custom_nodes_dir = Path(__file__).parent
|
||||
|
||||
for d in Path(__file__).parent.iterdir():
|
||||
for d in custom_nodes_dir.iterdir():
|
||||
# skip files
|
||||
if not d.is_dir():
|
||||
continue
|
||||
@@ -47,12 +49,16 @@ for d in Path(__file__).parent.iterdir():
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
loaded_count += 1
|
||||
loaded_packs.append(module_name)
|
||||
except Exception:
|
||||
failed_packs.append(module_name)
|
||||
full_error = traceback.format_exc()
|
||||
logger.error(f"Failed to load node pack {module_name}:\n{full_error}")
|
||||
logger.error(f"Failed to load node pack {module_name} (may have partially loaded):\n{full_error}")
|
||||
|
||||
del init, module_name
|
||||
|
||||
loaded_count = len(loaded_packs)
|
||||
if loaded_count > 0:
|
||||
logger.info(f"Loaded {loaded_count} node packs from {Path(__file__).parent}")
|
||||
logger.info(
|
||||
f"Loaded {loaded_count} node pack{'s' if loaded_count != 1 else ''} from {custom_nodes_dir}: {', '.join(loaded_packs)}"
|
||||
)
|
||||
|
||||
@@ -898,7 +898,7 @@ class DenoiseLatentsInvocation(BaseInvocation):
|
||||
|
||||
### inpaint
|
||||
mask, masked_latents, is_gradient_mask = self.prep_inpaint_mask(context, latents)
|
||||
# NOTE: We used to identify inpainting models by inpecting the shape of the loaded UNet model weights. Now we
|
||||
# NOTE: We used to identify inpainting models by inspecting the shape of the loaded UNet model weights. Now we
|
||||
# use the ModelVariantType config. During testing, there was a report of a user with models that had an
|
||||
# incorrect ModelVariantType value. Re-installing the model fixed the issue. If this issue turns out to be
|
||||
# prevalent, we will have to revisit how we initialize the inpainting extensions.
|
||||
|
||||
@@ -41,16 +41,11 @@ class FluxVaeDecodeInvocation(BaseInvocation, WithMetadata, WithBoard):
|
||||
|
||||
def _estimate_working_memory(self, latents: torch.Tensor, vae: AutoEncoder) -> int:
|
||||
"""Estimate the working memory required by the invocation in bytes."""
|
||||
# It was found experimentally that the peak working memory scales linearly with the number of pixels and the
|
||||
# element size (precision).
|
||||
out_h = LATENT_SCALE_FACTOR * latents.shape[-2]
|
||||
out_w = LATENT_SCALE_FACTOR * latents.shape[-1]
|
||||
element_size = next(vae.parameters()).element_size()
|
||||
scaling_constant = 1090 # Determined experimentally.
|
||||
scaling_constant = 2200 # Determined experimentally.
|
||||
working_memory = out_h * out_w * element_size * scaling_constant
|
||||
|
||||
# We add a 20% buffer to the working memory estimate to be safe.
|
||||
working_memory = working_memory * 1.2
|
||||
return int(working_memory)
|
||||
|
||||
def _vae_decode(self, vae_info: LoadedModel, latents: torch.Tensor) -> Image.Image:
|
||||
|
||||
@@ -918,7 +918,7 @@ class ImageChannelMultiplyInvocation(BaseInvocation, WithMetadata, WithBoard):
|
||||
invert_channel: bool = InputField(default=False, description="Invert the channel after scaling")
|
||||
|
||||
def invoke(self, context: InvocationContext) -> ImageOutput:
|
||||
image = context.images.get_pil(self.image.image_name)
|
||||
image = context.images.get_pil(self.image.image_name, "RGBA")
|
||||
|
||||
# extract the channel and mode from the input and reference tuple
|
||||
mode = CHANNEL_FORMATS[self.channel][0]
|
||||
|
||||
@@ -60,7 +60,7 @@ class LatentsToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
|
||||
# It was found experimentally that the peak working memory scales linearly with the number of pixels and the
|
||||
# element size (precision). This estimate is accurate for both SD1 and SDXL.
|
||||
element_size = 4 if self.fp32 else 2
|
||||
scaling_constant = 960 # Determined experimentally.
|
||||
scaling_constant = 2200 # Determined experimentally.
|
||||
|
||||
if use_tiling:
|
||||
tile_size = self.tile_size
|
||||
@@ -84,9 +84,7 @@ class LatentsToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
|
||||
# If we are running in FP32, then we should account for the likely increase in model size (~250MB).
|
||||
working_memory += 250 * 2**20
|
||||
|
||||
# We add 20% to the working memory estimate to be safe.
|
||||
working_memory = int(working_memory * 1.2)
|
||||
return working_memory
|
||||
return int(working_memory)
|
||||
|
||||
@torch.no_grad()
|
||||
def invoke(self, context: InvocationContext) -> ImageOutput:
|
||||
|
||||
40
invokeai/app/invocations/load_custom_nodes.py
Normal file
40
invokeai/app/invocations/load_custom_nodes.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import shutil
|
||||
import sys
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_custom_nodes(custom_nodes_path: Path):
|
||||
"""
|
||||
Loads all custom nodes from the custom_nodes_path directory.
|
||||
|
||||
This function copies a custom __init__.py file to the custom_nodes_path directory, effectively turning it into a
|
||||
python module.
|
||||
|
||||
The custom __init__.py file itself imports all the custom node packs as python modules from the custom_nodes_path
|
||||
directory.
|
||||
|
||||
Then,the custom __init__.py file is programmatically imported using importlib. As it executes, it imports all the
|
||||
custom node packs as python modules.
|
||||
"""
|
||||
custom_nodes_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
custom_nodes_init_path = str(custom_nodes_path / "__init__.py")
|
||||
custom_nodes_readme_path = str(custom_nodes_path / "README.md")
|
||||
|
||||
# copy our custom nodes __init__.py to the custom nodes directory
|
||||
shutil.copy(Path(__file__).parent / "custom_nodes/init.py", custom_nodes_init_path)
|
||||
shutil.copy(Path(__file__).parent / "custom_nodes/README.md", custom_nodes_readme_path)
|
||||
|
||||
# set the same permissions as the destination directory, in case our source is read-only,
|
||||
# so that the files are user-writable
|
||||
for p in custom_nodes_path.glob("**/*"):
|
||||
p.chmod(custom_nodes_path.stat().st_mode)
|
||||
|
||||
# Import custom nodes, see https://docs.python.org/3/library/importlib.html#importing-programmatically
|
||||
spec = spec_from_file_location("custom_nodes", custom_nodes_init_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Could not load custom nodes from {custom_nodes_init_path}")
|
||||
module = module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
@@ -18,6 +18,7 @@ from invokeai.app.invocations.fields import (
|
||||
UIType,
|
||||
)
|
||||
from invokeai.app.invocations.model import ModelIdentifierField
|
||||
from invokeai.app.invocations.primitives import StringOutput
|
||||
from invokeai.app.services.shared.invocation_context import InvocationContext
|
||||
from invokeai.app.util.controlnet_utils import CONTROLNET_MODE_VALUES, CONTROLNET_RESIZE_VALUES
|
||||
from invokeai.version.invokeai_version import __version__
|
||||
@@ -275,3 +276,34 @@ class CoreMetadataInvocation(BaseInvocation):
|
||||
return MetadataOutput(metadata=MetadataField.model_validate(as_dict))
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
@invocation(
|
||||
"metadata_field_extractor",
|
||||
title="Metadata Field Extractor",
|
||||
tags=["metadata"],
|
||||
category="metadata",
|
||||
version="1.0.0",
|
||||
classification=Classification.Deprecated,
|
||||
)
|
||||
class MetadataFieldExtractorInvocation(BaseInvocation):
|
||||
"""Extracts the text value from an image's metadata given a key.
|
||||
Raises an error if the image has no metadata or if the value is not a string (nesting not permitted)."""
|
||||
|
||||
image: ImageField = InputField(description="The image to extract metadata from")
|
||||
key: str = InputField(description="The key in the image's metadata to extract the value from")
|
||||
|
||||
def invoke(self, context: InvocationContext) -> StringOutput:
|
||||
image_name = self.image.image_name
|
||||
|
||||
metadata = context.images.get_metadata(image_name=image_name)
|
||||
if not metadata:
|
||||
raise ValueError(f"No metadata found on image {image_name}")
|
||||
|
||||
try:
|
||||
val = metadata.root[self.key]
|
||||
if not isinstance(val, str):
|
||||
raise ValueError(f"Metadata at key '{self.key}' must be a string")
|
||||
return StringOutput(value=val)
|
||||
except KeyError as e:
|
||||
raise ValueError(f"No key '{self.key}' found in the metadata for {image_name}") from e
|
||||
|
||||
1164
invokeai/app/invocations/metadata_linked.py
Normal file
1164
invokeai/app/invocations/metadata_linked.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -265,13 +265,9 @@ class ImageInvocation(BaseInvocation):
|
||||
image: ImageField = InputField(description="The image to load")
|
||||
|
||||
def invoke(self, context: InvocationContext) -> ImageOutput:
|
||||
image = context.images.get_pil(self.image.image_name)
|
||||
image_dto = context.images.get_dto(self.image.image_name)
|
||||
|
||||
return ImageOutput(
|
||||
image=ImageField(image_name=self.image.image_name),
|
||||
width=image.width,
|
||||
height=image.height,
|
||||
)
|
||||
return ImageOutput.build(image_dto=image_dto)
|
||||
|
||||
|
||||
@invocation(
|
||||
|
||||
@@ -43,16 +43,11 @@ class SD3LatentsToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
|
||||
|
||||
def _estimate_working_memory(self, latents: torch.Tensor, vae: AutoencoderKL) -> int:
|
||||
"""Estimate the working memory required by the invocation in bytes."""
|
||||
# It was found experimentally that the peak working memory scales linearly with the number of pixels and the
|
||||
# element size (precision).
|
||||
out_h = LATENT_SCALE_FACTOR * latents.shape[-2]
|
||||
out_w = LATENT_SCALE_FACTOR * latents.shape[-1]
|
||||
element_size = next(vae.parameters()).element_size()
|
||||
scaling_constant = 1230 # Determined experimentally.
|
||||
scaling_constant = 2200 # Determined experimentally.
|
||||
working_memory = out_h * out_w * element_size * scaling_constant
|
||||
|
||||
# We add a 20% buffer to the working memory estimate to be safe.
|
||||
working_memory = working_memory * 1.2
|
||||
return int(working_memory)
|
||||
|
||||
@torch.no_grad()
|
||||
|
||||
@@ -9,6 +9,6 @@ def validate_weights(weights: Union[float, list[float]]) -> None:
|
||||
|
||||
|
||||
def validate_begin_end_step(begin_step_percent: float, end_step_percent: float) -> None:
|
||||
"""Validate that begin_step_percent is less than end_step_percent"""
|
||||
if begin_step_percent >= end_step_percent:
|
||||
"""Validate that begin_step_percent is less than or equal to end_step_percent"""
|
||||
if begin_step_percent > end_step_percent:
|
||||
raise ValueError("Begin step percent must be less than or equal to end step percent")
|
||||
|
||||
@@ -1,12 +1,82 @@
|
||||
"""This is a wrapper around the main app entrypoint, to allow for CLI args to be parsed before running the app."""
|
||||
import uvicorn
|
||||
|
||||
from invokeai.app.invocations.load_custom_nodes import load_custom_nodes
|
||||
from invokeai.app.services.config.config_default import get_config
|
||||
from invokeai.app.util.torch_cuda_allocator import configure_torch_cuda_allocator
|
||||
from invokeai.backend.util.logging import InvokeAILogger
|
||||
from invokeai.frontend.cli.arg_parser import InvokeAIArgs
|
||||
|
||||
|
||||
def get_app():
|
||||
"""Import the app and event loop. We wrap this in a function to more explicitly control when it happens, because
|
||||
importing from api_app does a bunch of stuff - it's more like calling a function than importing a module.
|
||||
"""
|
||||
from invokeai.app.api_app import app, loop
|
||||
|
||||
return app, loop
|
||||
|
||||
|
||||
def run_app() -> None:
|
||||
# Before doing _anything_, parse CLI args!
|
||||
from invokeai.frontend.cli.arg_parser import InvokeAIArgs
|
||||
|
||||
"""The main entrypoint for the app."""
|
||||
# Parse the CLI arguments.
|
||||
InvokeAIArgs.parse_args()
|
||||
|
||||
from invokeai.app.api_app import invoke_api
|
||||
# Load config.
|
||||
app_config = get_config()
|
||||
|
||||
invoke_api()
|
||||
logger = InvokeAILogger.get_logger(config=app_config)
|
||||
|
||||
# Configure the torch CUDA memory allocator.
|
||||
# NOTE: It is important that this happens before torch is imported.
|
||||
if app_config.pytorch_cuda_alloc_conf:
|
||||
configure_torch_cuda_allocator(app_config.pytorch_cuda_alloc_conf, logger)
|
||||
|
||||
# Import from startup_utils here to avoid importing torch before configure_torch_cuda_allocator() is called.
|
||||
from invokeai.app.util.startup_utils import (
|
||||
apply_monkeypatches,
|
||||
check_cudnn,
|
||||
enable_dev_reload,
|
||||
find_open_port,
|
||||
register_mime_types,
|
||||
)
|
||||
|
||||
# Find an open port, and modify the config accordingly.
|
||||
orig_config_port = app_config.port
|
||||
app_config.port = find_open_port(app_config.port)
|
||||
if orig_config_port != app_config.port:
|
||||
logger.warning(f"Port {orig_config_port} is already in use. Using port {app_config.port}.")
|
||||
|
||||
# Miscellaneous startup tasks.
|
||||
apply_monkeypatches()
|
||||
register_mime_types()
|
||||
if app_config.dev_reload:
|
||||
enable_dev_reload()
|
||||
check_cudnn(logger)
|
||||
|
||||
# Initialize the app and event loop.
|
||||
app, loop = get_app()
|
||||
|
||||
# Load custom nodes. This must be done after importing the Graph class, which itself imports all modules from the
|
||||
# invocations module. The ordering here is implicit, but important - we want to load custom nodes after all the
|
||||
# core nodes have been imported so that we can catch when a custom node clobbers a core node.
|
||||
load_custom_nodes(custom_nodes_path=app_config.custom_nodes_path)
|
||||
|
||||
# Start the server.
|
||||
config = uvicorn.Config(
|
||||
app=app,
|
||||
host=app_config.host,
|
||||
port=app_config.port,
|
||||
loop="asyncio",
|
||||
log_level=app_config.log_level_network,
|
||||
ssl_certfile=app_config.ssl_certfile,
|
||||
ssl_keyfile=app_config.ssl_keyfile,
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
# replace uvicorn's loggers with InvokeAI's for consistent appearance
|
||||
uvicorn_logger = InvokeAILogger.get_logger("uvicorn")
|
||||
uvicorn_logger.handlers.clear()
|
||||
for hdlr in logger.handlers:
|
||||
uvicorn_logger.addHandler(hdlr)
|
||||
|
||||
loop.run_until_complete(server.serve())
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
from invokeai.app.services.image_records.image_records_common import ImageCategory
|
||||
|
||||
|
||||
class BoardImageRecordStorageBase(ABC):
|
||||
"""Abstract base class for the one-to-many board-image relationship record storage."""
|
||||
@@ -26,6 +28,8 @@ class BoardImageRecordStorageBase(ABC):
|
||||
def get_all_board_image_names_for_board(
|
||||
self,
|
||||
board_id: str,
|
||||
categories: list[ImageCategory] | None,
|
||||
is_intermediate: bool | None,
|
||||
) -> list[str]:
|
||||
"""Gets all board images for a board, as a list of the image names."""
|
||||
pass
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import Optional, cast
|
||||
|
||||
from invokeai.app.services.board_image_records.board_image_records_base import BoardImageRecordStorageBase
|
||||
from invokeai.app.services.image_records.image_records_common import ImageRecord, deserialize_image_record
|
||||
from invokeai.app.services.image_records.image_records_common import (
|
||||
ImageCategory,
|
||||
ImageRecord,
|
||||
deserialize_image_record,
|
||||
)
|
||||
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
|
||||
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
|
||||
|
||||
|
||||
class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
|
||||
_conn: sqlite3.Connection
|
||||
_cursor: sqlite3.Cursor
|
||||
_lock: threading.RLock
|
||||
|
||||
def __init__(self, db: SqliteDatabase) -> None:
|
||||
super().__init__()
|
||||
self._lock = db.lock
|
||||
self._conn = db.conn
|
||||
self._cursor = self._conn.cursor()
|
||||
|
||||
def add_image_to_board(
|
||||
self,
|
||||
@@ -25,8 +22,8 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
|
||||
image_name: str,
|
||||
) -> None:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT INTO board_images (board_id, image_name)
|
||||
VALUES (?, ?)
|
||||
@@ -38,16 +35,14 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
|
||||
except sqlite3.Error as e:
|
||||
self._conn.rollback()
|
||||
raise e
|
||||
finally:
|
||||
self._lock.release()
|
||||
|
||||
def remove_image_from_board(
|
||||
self,
|
||||
image_name: str,
|
||||
) -> None:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
DELETE FROM board_images
|
||||
WHERE image_name = ?;
|
||||
@@ -58,8 +53,6 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
|
||||
except sqlite3.Error as e:
|
||||
self._conn.rollback()
|
||||
raise e
|
||||
finally:
|
||||
self._lock.release()
|
||||
|
||||
def get_images_for_board(
|
||||
self,
|
||||
@@ -68,96 +61,108 @@ class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
|
||||
limit: int = 10,
|
||||
) -> OffsetPaginatedResults[ImageRecord]:
|
||||
# TODO: this isn't paginated yet?
|
||||
try:
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
"""--sql
|
||||
SELECT images.*
|
||||
FROM board_images
|
||||
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())
|
||||
images = [deserialize_image_record(dict(r)) for r in result]
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT images.*
|
||||
FROM board_images
|
||||
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], cursor.fetchall())
|
||||
images = [deserialize_image_record(dict(r)) for r in result]
|
||||
|
||||
self._cursor.execute(
|
||||
"""--sql
|
||||
SELECT COUNT(*) FROM images WHERE 1=1;
|
||||
"""
|
||||
)
|
||||
count = cast(int, self._cursor.fetchone()[0])
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT COUNT(*) FROM images WHERE 1=1;
|
||||
"""
|
||||
)
|
||||
count = cast(int, cursor.fetchone()[0])
|
||||
|
||||
except sqlite3.Error as e:
|
||||
self._conn.rollback()
|
||||
raise e
|
||||
finally:
|
||||
self._lock.release()
|
||||
return OffsetPaginatedResults(items=images, offset=offset, limit=limit, total=count)
|
||||
|
||||
def get_all_board_image_names_for_board(self, board_id: str) -> list[str]:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
"""--sql
|
||||
SELECT image_name
|
||||
FROM board_images
|
||||
WHERE board_id = ?;
|
||||
""",
|
||||
(board_id,),
|
||||
)
|
||||
result = cast(list[sqlite3.Row], self._cursor.fetchall())
|
||||
image_names = [r[0] for r in result]
|
||||
return image_names
|
||||
except sqlite3.Error as e:
|
||||
self._conn.rollback()
|
||||
raise e
|
||||
finally:
|
||||
self._lock.release()
|
||||
def get_all_board_image_names_for_board(
|
||||
self,
|
||||
board_id: str,
|
||||
categories: list[ImageCategory] | None,
|
||||
is_intermediate: bool | None,
|
||||
) -> list[str]:
|
||||
params: list[str | bool] = []
|
||||
|
||||
# Base query is a join between images and board_images
|
||||
stmt = """
|
||||
SELECT images.image_name
|
||||
FROM images
|
||||
LEFT JOIN board_images ON board_images.image_name = images.image_name
|
||||
WHERE 1=1
|
||||
AND board_images.board_id = ?
|
||||
"""
|
||||
params.append(board_id)
|
||||
|
||||
# Add the category filter
|
||||
if categories is not None:
|
||||
# Convert the enum values to unique list of strings
|
||||
category_strings = [c.value for c in set(categories)]
|
||||
# Create the correct length of placeholders
|
||||
placeholders = ",".join("?" * len(category_strings))
|
||||
stmt += f"""--sql
|
||||
AND images.image_category IN ( {placeholders} )
|
||||
"""
|
||||
|
||||
# Unpack the included categories into the query params
|
||||
for c in category_strings:
|
||||
params.append(c)
|
||||
|
||||
# Add the is_intermediate filter
|
||||
if is_intermediate is not None:
|
||||
stmt += """--sql
|
||||
AND images.is_intermediate = ?
|
||||
"""
|
||||
params.append(is_intermediate)
|
||||
|
||||
# Put a ring on it
|
||||
stmt += ";"
|
||||
|
||||
# Execute the query
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(stmt, params)
|
||||
|
||||
result = cast(list[sqlite3.Row], cursor.fetchall())
|
||||
image_names = [r[0] for r in result]
|
||||
return image_names
|
||||
|
||||
def get_board_for_image(
|
||||
self,
|
||||
image_name: str,
|
||||
) -> Optional[str]:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
"""--sql
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT board_id
|
||||
FROM board_images
|
||||
WHERE image_name = ?;
|
||||
""",
|
||||
(image_name,),
|
||||
)
|
||||
result = self._cursor.fetchone()
|
||||
if result is None:
|
||||
return None
|
||||
return cast(str, result[0])
|
||||
except sqlite3.Error as e:
|
||||
self._conn.rollback()
|
||||
raise e
|
||||
finally:
|
||||
self._lock.release()
|
||||
(image_name,),
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
if result is None:
|
||||
return None
|
||||
return cast(str, result[0])
|
||||
|
||||
def get_image_count_for_board(self, board_id: str) -> int:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
"""--sql
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT COUNT(*)
|
||||
FROM board_images
|
||||
INNER JOIN images ON board_images.image_name = images.image_name
|
||||
WHERE images.is_intermediate = FALSE
|
||||
AND board_images.board_id = ?;
|
||||
""",
|
||||
(board_id,),
|
||||
)
|
||||
count = cast(int, self._cursor.fetchone()[0])
|
||||
return count
|
||||
except sqlite3.Error as e:
|
||||
self._conn.rollback()
|
||||
raise e
|
||||
finally:
|
||||
self._lock.release()
|
||||
(board_id,),
|
||||
)
|
||||
count = cast(int, cursor.fetchone()[0])
|
||||
return count
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
from invokeai.app.services.image_records.image_records_common import ImageCategory
|
||||
|
||||
|
||||
class BoardImagesServiceABC(ABC):
|
||||
"""High-level service for board-image relationship management."""
|
||||
@@ -26,6 +28,8 @@ class BoardImagesServiceABC(ABC):
|
||||
def get_all_board_image_names_for_board(
|
||||
self,
|
||||
board_id: str,
|
||||
categories: list[ImageCategory] | None,
|
||||
is_intermediate: bool | None,
|
||||
) -> list[str]:
|
||||
"""Gets all board images for a board, as a list of the image names."""
|
||||
pass
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from typing import Optional
|
||||
|
||||
from invokeai.app.services.board_images.board_images_base import BoardImagesServiceABC
|
||||
from invokeai.app.services.image_records.image_records_common import ImageCategory
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
|
||||
|
||||
@@ -26,8 +27,14 @@ class BoardImagesService(BoardImagesServiceABC):
|
||||
def get_all_board_image_names_for_board(
|
||||
self,
|
||||
board_id: str,
|
||||
categories: list[ImageCategory] | None,
|
||||
is_intermediate: bool | None,
|
||||
) -> list[str]:
|
||||
return self.__invoker.services.board_image_records.get_all_board_image_names_for_board(board_id)
|
||||
return self.__invoker.services.board_image_records.get_all_board_image_names_for_board(
|
||||
board_id,
|
||||
categories,
|
||||
is_intermediate,
|
||||
)
|
||||
|
||||
def get_board_for_image(
|
||||
self,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import Union, cast
|
||||
|
||||
from invokeai.app.services.board_records.board_records_base import BoardRecordStorageBase
|
||||
@@ -19,20 +18,14 @@ from invokeai.app.util.misc import uuid_string
|
||||
|
||||
|
||||
class SqliteBoardRecordStorage(BoardRecordStorageBase):
|
||||
_conn: sqlite3.Connection
|
||||
_cursor: sqlite3.Cursor
|
||||
_lock: threading.RLock
|
||||
|
||||
def __init__(self, db: SqliteDatabase) -> None:
|
||||
super().__init__()
|
||||
self._lock = db.lock
|
||||
self._conn = db.conn
|
||||
self._cursor = self._conn.cursor()
|
||||
|
||||
def delete(self, board_id: str) -> None:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
DELETE FROM boards
|
||||
WHERE board_id = ?;
|
||||
@@ -40,14 +33,9 @@ class SqliteBoardRecordStorage(BoardRecordStorageBase):
|
||||
(board_id,),
|
||||
)
|
||||
self._conn.commit()
|
||||
except sqlite3.Error as e:
|
||||
self._conn.rollback()
|
||||
raise BoardRecordDeleteException from e
|
||||
except Exception as e:
|
||||
self._conn.rollback()
|
||||
raise BoardRecordDeleteException from e
|
||||
finally:
|
||||
self._lock.release()
|
||||
|
||||
def save(
|
||||
self,
|
||||
@@ -55,8 +43,8 @@ class SqliteBoardRecordStorage(BoardRecordStorageBase):
|
||||
) -> BoardRecord:
|
||||
try:
|
||||
board_id = uuid_string()
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT OR IGNORE INTO boards (board_id, board_name)
|
||||
VALUES (?, ?);
|
||||
@@ -67,8 +55,6 @@ class SqliteBoardRecordStorage(BoardRecordStorageBase):
|
||||
except sqlite3.Error as e:
|
||||
self._conn.rollback()
|
||||
raise BoardRecordSaveException from e
|
||||
finally:
|
||||
self._lock.release()
|
||||
return self.get(board_id)
|
||||
|
||||
def get(
|
||||
@@ -76,8 +62,8 @@ class SqliteBoardRecordStorage(BoardRecordStorageBase):
|
||||
board_id: str,
|
||||
) -> BoardRecord:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT *
|
||||
FROM boards
|
||||
@@ -86,12 +72,9 @@ class SqliteBoardRecordStorage(BoardRecordStorageBase):
|
||||
(board_id,),
|
||||
)
|
||||
|
||||
result = cast(Union[sqlite3.Row, None], self._cursor.fetchone())
|
||||
result = cast(Union[sqlite3.Row, None], cursor.fetchone())
|
||||
except sqlite3.Error as e:
|
||||
self._conn.rollback()
|
||||
raise BoardRecordNotFoundException from e
|
||||
finally:
|
||||
self._lock.release()
|
||||
if result is None:
|
||||
raise BoardRecordNotFoundException
|
||||
return BoardRecord(**dict(result))
|
||||
@@ -102,11 +85,10 @@ class SqliteBoardRecordStorage(BoardRecordStorageBase):
|
||||
changes: BoardChanges,
|
||||
) -> BoardRecord:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
|
||||
cursor = self._conn.cursor()
|
||||
# Change the name of a board
|
||||
if changes.board_name is not None:
|
||||
self._cursor.execute(
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
UPDATE boards
|
||||
SET board_name = ?
|
||||
@@ -117,7 +99,7 @@ class SqliteBoardRecordStorage(BoardRecordStorageBase):
|
||||
|
||||
# Change the cover image of a board
|
||||
if changes.cover_image_name is not None:
|
||||
self._cursor.execute(
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
UPDATE boards
|
||||
SET cover_image_name = ?
|
||||
@@ -128,7 +110,7 @@ class SqliteBoardRecordStorage(BoardRecordStorageBase):
|
||||
|
||||
# Change the archived status of a board
|
||||
if changes.archived is not None:
|
||||
self._cursor.execute(
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
UPDATE boards
|
||||
SET archived = ?
|
||||
@@ -141,8 +123,6 @@ class SqliteBoardRecordStorage(BoardRecordStorageBase):
|
||||
except sqlite3.Error as e:
|
||||
self._conn.rollback()
|
||||
raise BoardRecordSaveException from e
|
||||
finally:
|
||||
self._lock.release()
|
||||
return self.get(board_id)
|
||||
|
||||
def get_many(
|
||||
@@ -153,11 +133,10 @@ class SqliteBoardRecordStorage(BoardRecordStorageBase):
|
||||
limit: int = 10,
|
||||
include_archived: bool = False,
|
||||
) -> OffsetPaginatedResults[BoardRecord]:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
cursor = self._conn.cursor()
|
||||
|
||||
# Build base query
|
||||
base_query = """
|
||||
# Build base query
|
||||
base_query = """
|
||||
SELECT *
|
||||
FROM boards
|
||||
{archived_filter}
|
||||
@@ -165,81 +144,67 @@ class SqliteBoardRecordStorage(BoardRecordStorageBase):
|
||||
LIMIT ? OFFSET ?;
|
||||
"""
|
||||
|
||||
# Determine archived filter condition
|
||||
archived_filter = "" if include_archived else "WHERE archived = 0"
|
||||
# Determine archived filter condition
|
||||
archived_filter = "" if include_archived else "WHERE archived = 0"
|
||||
|
||||
final_query = base_query.format(
|
||||
archived_filter=archived_filter, order_by=order_by.value, direction=direction.value
|
||||
)
|
||||
final_query = base_query.format(
|
||||
archived_filter=archived_filter, order_by=order_by.value, direction=direction.value
|
||||
)
|
||||
|
||||
# Execute query to fetch boards
|
||||
self._cursor.execute(final_query, (limit, offset))
|
||||
# Execute query to fetch boards
|
||||
cursor.execute(final_query, (limit, offset))
|
||||
|
||||
result = cast(list[sqlite3.Row], self._cursor.fetchall())
|
||||
boards = [deserialize_board_record(dict(r)) for r in result]
|
||||
result = cast(list[sqlite3.Row], cursor.fetchall())
|
||||
boards = [deserialize_board_record(dict(r)) for r in result]
|
||||
|
||||
# Determine count query
|
||||
if include_archived:
|
||||
count_query = """
|
||||
# Determine count query
|
||||
if include_archived:
|
||||
count_query = """
|
||||
SELECT COUNT(*)
|
||||
FROM boards;
|
||||
"""
|
||||
else:
|
||||
count_query = """
|
||||
else:
|
||||
count_query = """
|
||||
SELECT COUNT(*)
|
||||
FROM boards
|
||||
WHERE archived = 0;
|
||||
"""
|
||||
|
||||
# Execute count query
|
||||
self._cursor.execute(count_query)
|
||||
# Execute count query
|
||||
cursor.execute(count_query)
|
||||
|
||||
count = cast(int, self._cursor.fetchone()[0])
|
||||
count = cast(int, cursor.fetchone()[0])
|
||||
|
||||
return OffsetPaginatedResults[BoardRecord](items=boards, offset=offset, limit=limit, total=count)
|
||||
|
||||
except sqlite3.Error as e:
|
||||
self._conn.rollback()
|
||||
raise e
|
||||
finally:
|
||||
self._lock.release()
|
||||
return OffsetPaginatedResults[BoardRecord](items=boards, offset=offset, limit=limit, total=count)
|
||||
|
||||
def get_all(
|
||||
self, order_by: BoardRecordOrderBy, direction: SQLiteDirection, include_archived: bool = False
|
||||
) -> list[BoardRecord]:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
|
||||
if order_by == BoardRecordOrderBy.Name:
|
||||
base_query = """
|
||||
cursor = self._conn.cursor()
|
||||
if order_by == BoardRecordOrderBy.Name:
|
||||
base_query = """
|
||||
SELECT *
|
||||
FROM boards
|
||||
{archived_filter}
|
||||
ORDER BY LOWER(board_name) {direction}
|
||||
"""
|
||||
else:
|
||||
base_query = """
|
||||
else:
|
||||
base_query = """
|
||||
SELECT *
|
||||
FROM boards
|
||||
{archived_filter}
|
||||
ORDER BY {order_by} {direction}
|
||||
"""
|
||||
|
||||
archived_filter = "" if include_archived else "WHERE archived = 0"
|
||||
archived_filter = "" if include_archived else "WHERE archived = 0"
|
||||
|
||||
final_query = base_query.format(
|
||||
archived_filter=archived_filter, order_by=order_by.value, direction=direction.value
|
||||
)
|
||||
final_query = base_query.format(
|
||||
archived_filter=archived_filter, order_by=order_by.value, direction=direction.value
|
||||
)
|
||||
|
||||
self._cursor.execute(final_query)
|
||||
cursor.execute(final_query)
|
||||
|
||||
result = cast(list[sqlite3.Row], self._cursor.fetchall())
|
||||
boards = [deserialize_board_record(dict(r)) for r in result]
|
||||
result = cast(list[sqlite3.Row], cursor.fetchall())
|
||||
boards = [deserialize_board_record(dict(r)) for r in result]
|
||||
|
||||
return boards
|
||||
|
||||
except sqlite3.Error as e:
|
||||
self._conn.rollback()
|
||||
raise e
|
||||
finally:
|
||||
self._lock.release()
|
||||
return boards
|
||||
|
||||
@@ -63,7 +63,11 @@ class BulkDownloadService(BulkDownloadBase):
|
||||
return [self._invoker.services.images.get_dto(image_name) for image_name in image_names]
|
||||
|
||||
def _board_handler(self, board_id: str) -> list[ImageDTO]:
|
||||
image_names = self._invoker.services.board_image_records.get_all_board_image_names_for_board(board_id)
|
||||
image_names = self._invoker.services.board_image_records.get_all_board_image_names_for_board(
|
||||
board_id,
|
||||
categories=None,
|
||||
is_intermediate=None,
|
||||
)
|
||||
return self._image_handler(image_names)
|
||||
|
||||
def generate_item_id(self, board_id: Optional[str]) -> str:
|
||||
|
||||
@@ -91,6 +91,7 @@ class InvokeAIAppConfig(BaseSettings):
|
||||
ram: DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_ram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.
|
||||
vram: DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_vram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.
|
||||
lazy_offload: DEPRECATED: This setting is no longer used. Lazy-offloading is enabled by default. This config setting will be removed once the new model cache behavior is stable.
|
||||
pytorch_cuda_alloc_conf: Configure the Torch CUDA memory allocator. This will impact peak reserved VRAM usage and performance. Setting to "backend:cudaMallocAsync" works well on many systems. The optimal configuration is highly dependent on the system configuration (device type, VRAM, CUDA driver version, etc.), so must be tuned experimentally.
|
||||
device: Preferred execution device. `auto` will choose the device depending on the hardware platform and the installed torch capabilities.<br>Valid values: `auto`, `cpu`, `cuda`, `cuda:1`, `mps`
|
||||
precision: Floating point precision. `float16` will consume half the memory of `float32` but produce slightly lower-quality images. The `auto` setting will guess the proper precision based on your video card and operating system.<br>Valid values: `auto`, `float16`, `bfloat16`, `float32`
|
||||
sequential_guidance: Whether to calculate guidance in serial instead of in parallel, lowering memory requirements.
|
||||
@@ -169,6 +170,9 @@ class InvokeAIAppConfig(BaseSettings):
|
||||
vram: Optional[float] = Field(default=None, ge=0, description="DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_vram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.")
|
||||
lazy_offload: bool = Field(default=True, description="DEPRECATED: This setting is no longer used. Lazy-offloading is enabled by default. This config setting will be removed once the new model cache behavior is stable.")
|
||||
|
||||
# PyTorch Memory Allocator
|
||||
pytorch_cuda_alloc_conf: Optional[str] = Field(default=None, description="Configure the Torch CUDA memory allocator. This will impact peak reserved VRAM usage and performance. Setting to \"backend:cudaMallocAsync\" works well on many systems. The optimal configuration is highly dependent on the system configuration (device type, VRAM, CUDA driver version, etc.), so must be tuned experimentally.")
|
||||
|
||||
# DEVICE
|
||||
device: DEVICE = Field(default="auto", description="Preferred execution device. `auto` will choose the device depending on the hardware platform and the installed torch capabilities.")
|
||||
precision: PRECISION = Field(default="auto", description="Floating point precision. `float16` will consume half the memory of `float32` but produce slightly lower-quality images. The `auto` setting will guess the proper precision based on your video card and operating system.")
|
||||
|
||||
@@ -28,6 +28,7 @@ from invokeai.app.services.events.events_common import (
|
||||
ModelLoadCompleteEvent,
|
||||
ModelLoadStartedEvent,
|
||||
QueueClearedEvent,
|
||||
QueueItemsRetriedEvent,
|
||||
QueueItemStatusChangedEvent,
|
||||
)
|
||||
|
||||
@@ -39,6 +40,7 @@ if TYPE_CHECKING:
|
||||
from invokeai.app.services.session_queue.session_queue_common import (
|
||||
BatchStatus,
|
||||
EnqueueBatchResult,
|
||||
RetryItemsResult,
|
||||
SessionQueueItem,
|
||||
SessionQueueStatus,
|
||||
)
|
||||
@@ -99,6 +101,10 @@ class EventServiceBase:
|
||||
"""Emitted when a batch is enqueued"""
|
||||
self.dispatch(BatchEnqueuedEvent.build(enqueue_result))
|
||||
|
||||
def emit_queue_items_retried(self, retry_result: "RetryItemsResult") -> None:
|
||||
"""Emitted when a list of queue items are retried"""
|
||||
self.dispatch(QueueItemsRetriedEvent.build(retry_result))
|
||||
|
||||
def emit_queue_cleared(self, queue_id: str) -> None:
|
||||
"""Emitted when a queue is cleared"""
|
||||
self.dispatch(QueueClearedEvent.build(queue_id))
|
||||
|
||||
@@ -10,6 +10,7 @@ from invokeai.app.services.session_queue.session_queue_common import (
|
||||
QUEUE_ITEM_STATUS,
|
||||
BatchStatus,
|
||||
EnqueueBatchResult,
|
||||
RetryItemsResult,
|
||||
SessionQueueItem,
|
||||
SessionQueueStatus,
|
||||
)
|
||||
@@ -290,6 +291,22 @@ class BatchEnqueuedEvent(QueueEventBase):
|
||||
)
|
||||
|
||||
|
||||
@payload_schema.register
|
||||
class QueueItemsRetriedEvent(QueueEventBase):
|
||||
"""Event model for queue_items_retried"""
|
||||
|
||||
__event_name__ = "queue_items_retried"
|
||||
|
||||
retried_item_ids: list[int] = Field(description="The IDs of the queue items that were retried")
|
||||
|
||||
@classmethod
|
||||
def build(cls, retry_result: RetryItemsResult) -> "QueueItemsRetriedEvent":
|
||||
return cls(
|
||||
queue_id=retry_result.queue_id,
|
||||
retried_item_ids=retry_result.retried_item_ids,
|
||||
)
|
||||
|
||||
|
||||
@payload_schema.register
|
||||
class QueueClearedEvent(QueueEventBase):
|
||||
"""Event model for queue_cleared"""
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Optional, Union, cast
|
||||
|
||||
@@ -22,21 +21,14 @@ from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
|
||||
|
||||
|
||||
class SqliteImageRecordStorage(ImageRecordStorageBase):
|
||||
_conn: sqlite3.Connection
|
||||
_cursor: sqlite3.Cursor
|
||||
_lock: threading.RLock
|
||||
|
||||
def __init__(self, db: SqliteDatabase) -> None:
|
||||
super().__init__()
|
||||
self._lock = db.lock
|
||||
self._conn = db.conn
|
||||
self._cursor = self._conn.cursor()
|
||||
|
||||
def get(self, image_name: str) -> ImageRecord:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
|
||||
self._cursor.execute(
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
f"""--sql
|
||||
SELECT {IMAGE_DTO_COLS} FROM images
|
||||
WHERE image_name = ?;
|
||||
@@ -44,12 +36,9 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
|
||||
(image_name,),
|
||||
)
|
||||
|
||||
result = cast(Optional[sqlite3.Row], self._cursor.fetchone())
|
||||
result = cast(Optional[sqlite3.Row], cursor.fetchone())
|
||||
except sqlite3.Error as e:
|
||||
self._conn.rollback()
|
||||
raise ImageRecordNotFoundException from e
|
||||
finally:
|
||||
self._lock.release()
|
||||
|
||||
if not result:
|
||||
raise ImageRecordNotFoundException
|
||||
@@ -58,9 +47,8 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
|
||||
|
||||
def get_metadata(self, image_name: str) -> Optional[MetadataField]:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
|
||||
self._cursor.execute(
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT metadata FROM images
|
||||
WHERE image_name = ?;
|
||||
@@ -68,7 +56,7 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
|
||||
(image_name,),
|
||||
)
|
||||
|
||||
result = cast(Optional[sqlite3.Row], self._cursor.fetchone())
|
||||
result = cast(Optional[sqlite3.Row], cursor.fetchone())
|
||||
|
||||
if not result:
|
||||
raise ImageRecordNotFoundException
|
||||
@@ -77,10 +65,7 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
|
||||
metadata_raw = cast(Optional[str], as_dict.get("metadata", None))
|
||||
return MetadataFieldValidator.validate_json(metadata_raw) if metadata_raw is not None else None
|
||||
except sqlite3.Error as e:
|
||||
self._conn.rollback()
|
||||
raise ImageRecordNotFoundException from e
|
||||
finally:
|
||||
self._lock.release()
|
||||
|
||||
def update(
|
||||
self,
|
||||
@@ -88,10 +73,10 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
|
||||
changes: ImageRecordChanges,
|
||||
) -> None:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
cursor = self._conn.cursor()
|
||||
# Change the category of the image
|
||||
if changes.image_category is not None:
|
||||
self._cursor.execute(
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
UPDATE images
|
||||
SET image_category = ?
|
||||
@@ -102,7 +87,7 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
|
||||
|
||||
# Change the session associated with the image
|
||||
if changes.session_id is not None:
|
||||
self._cursor.execute(
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
UPDATE images
|
||||
SET session_id = ?
|
||||
@@ -113,7 +98,7 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
|
||||
|
||||
# Change the image's `is_intermediate`` flag
|
||||
if changes.is_intermediate is not None:
|
||||
self._cursor.execute(
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
UPDATE images
|
||||
SET is_intermediate = ?
|
||||
@@ -124,7 +109,7 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
|
||||
|
||||
# Change the image's `starred`` state
|
||||
if changes.starred is not None:
|
||||
self._cursor.execute(
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
UPDATE images
|
||||
SET starred = ?
|
||||
@@ -137,8 +122,6 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
|
||||
except sqlite3.Error as e:
|
||||
self._conn.rollback()
|
||||
raise ImageRecordSaveException from e
|
||||
finally:
|
||||
self._lock.release()
|
||||
|
||||
def get_many(
|
||||
self,
|
||||
@@ -152,110 +135,104 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
|
||||
board_id: Optional[str] = None,
|
||||
search_term: Optional[str] = None,
|
||||
) -> OffsetPaginatedResults[ImageRecord]:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
cursor = self._conn.cursor()
|
||||
|
||||
# Manually build two queries - one for the count, one for the records
|
||||
count_query = """--sql
|
||||
SELECT COUNT(*)
|
||||
FROM images
|
||||
LEFT JOIN board_images ON board_images.image_name = images.image_name
|
||||
WHERE 1=1
|
||||
# Manually build two queries - one for the count, one for the records
|
||||
count_query = """--sql
|
||||
SELECT COUNT(*)
|
||||
FROM images
|
||||
LEFT JOIN board_images ON board_images.image_name = images.image_name
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
images_query = f"""--sql
|
||||
SELECT {IMAGE_DTO_COLS}
|
||||
FROM images
|
||||
LEFT JOIN board_images ON board_images.image_name = images.image_name
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
query_conditions = ""
|
||||
query_params: list[Union[int, str, bool]] = []
|
||||
|
||||
if image_origin is not None:
|
||||
query_conditions += """--sql
|
||||
AND images.image_origin = ?
|
||||
"""
|
||||
query_params.append(image_origin.value)
|
||||
|
||||
if categories is not None:
|
||||
# Convert the enum values to unique list of strings
|
||||
category_strings = [c.value for c in set(categories)]
|
||||
# Create the correct length of placeholders
|
||||
placeholders = ",".join("?" * len(category_strings))
|
||||
|
||||
query_conditions += f"""--sql
|
||||
AND images.image_category IN ( {placeholders} )
|
||||
"""
|
||||
|
||||
images_query = f"""--sql
|
||||
SELECT {IMAGE_DTO_COLS}
|
||||
FROM images
|
||||
LEFT JOIN board_images ON board_images.image_name = images.image_name
|
||||
WHERE 1=1
|
||||
# Unpack the included categories into the query params
|
||||
for c in category_strings:
|
||||
query_params.append(c)
|
||||
|
||||
if is_intermediate is not None:
|
||||
query_conditions += """--sql
|
||||
AND images.is_intermediate = ?
|
||||
"""
|
||||
|
||||
query_conditions = ""
|
||||
query_params: list[Union[int, str, bool]] = []
|
||||
query_params.append(is_intermediate)
|
||||
|
||||
if image_origin is not None:
|
||||
query_conditions += """--sql
|
||||
AND images.image_origin = ?
|
||||
"""
|
||||
query_params.append(image_origin.value)
|
||||
# board_id of "none" is reserved for images without a board
|
||||
if board_id == "none":
|
||||
query_conditions += """--sql
|
||||
AND board_images.board_id IS NULL
|
||||
"""
|
||||
elif board_id is not None:
|
||||
query_conditions += """--sql
|
||||
AND board_images.board_id = ?
|
||||
"""
|
||||
query_params.append(board_id)
|
||||
|
||||
if categories is not None:
|
||||
# Convert the enum values to unique list of strings
|
||||
category_strings = [c.value for c in set(categories)]
|
||||
# Create the correct length of placeholders
|
||||
placeholders = ",".join("?" * len(category_strings))
|
||||
# Search term condition
|
||||
if search_term:
|
||||
query_conditions += """--sql
|
||||
AND images.metadata LIKE ?
|
||||
"""
|
||||
query_params.append(f"%{search_term.lower()}%")
|
||||
|
||||
query_conditions += f"""--sql
|
||||
AND images.image_category IN ( {placeholders} )
|
||||
"""
|
||||
if starred_first:
|
||||
query_pagination = f"""--sql
|
||||
ORDER BY images.starred DESC, images.created_at {order_dir.value} LIMIT ? OFFSET ?
|
||||
"""
|
||||
else:
|
||||
query_pagination = f"""--sql
|
||||
ORDER BY images.created_at {order_dir.value} LIMIT ? OFFSET ?
|
||||
"""
|
||||
|
||||
# Unpack the included categories into the query params
|
||||
for c in category_strings:
|
||||
query_params.append(c)
|
||||
# Final images query with pagination
|
||||
images_query += query_conditions + query_pagination + ";"
|
||||
# Add all the parameters
|
||||
images_params = query_params.copy()
|
||||
# Add the pagination parameters
|
||||
images_params.extend([limit, offset])
|
||||
|
||||
if is_intermediate is not None:
|
||||
query_conditions += """--sql
|
||||
AND images.is_intermediate = ?
|
||||
"""
|
||||
# Build the list of images, deserializing each row
|
||||
cursor.execute(images_query, images_params)
|
||||
result = cast(list[sqlite3.Row], cursor.fetchall())
|
||||
images = [deserialize_image_record(dict(r)) for r in result]
|
||||
|
||||
query_params.append(is_intermediate)
|
||||
|
||||
# board_id of "none" is reserved for images without a board
|
||||
if board_id == "none":
|
||||
query_conditions += """--sql
|
||||
AND board_images.board_id IS NULL
|
||||
"""
|
||||
elif board_id is not None:
|
||||
query_conditions += """--sql
|
||||
AND board_images.board_id = ?
|
||||
"""
|
||||
query_params.append(board_id)
|
||||
|
||||
# Search term condition
|
||||
if search_term:
|
||||
query_conditions += """--sql
|
||||
AND images.metadata LIKE ?
|
||||
"""
|
||||
query_params.append(f"%{search_term.lower()}%")
|
||||
|
||||
if starred_first:
|
||||
query_pagination = f"""--sql
|
||||
ORDER BY images.starred DESC, images.created_at {order_dir.value} LIMIT ? OFFSET ?
|
||||
"""
|
||||
else:
|
||||
query_pagination = f"""--sql
|
||||
ORDER BY images.created_at {order_dir.value} LIMIT ? OFFSET ?
|
||||
"""
|
||||
|
||||
# Final images query with pagination
|
||||
images_query += query_conditions + query_pagination + ";"
|
||||
# Add all the parameters
|
||||
images_params = query_params.copy()
|
||||
# Add the pagination parameters
|
||||
images_params.extend([limit, offset])
|
||||
|
||||
# Build the list of images, deserializing each row
|
||||
self._cursor.execute(images_query, images_params)
|
||||
result = cast(list[sqlite3.Row], self._cursor.fetchall())
|
||||
images = [deserialize_image_record(dict(r)) for r in result]
|
||||
|
||||
# Set up and execute the count query, without pagination
|
||||
count_query += query_conditions + ";"
|
||||
count_params = query_params.copy()
|
||||
self._cursor.execute(count_query, count_params)
|
||||
count = cast(int, self._cursor.fetchone()[0])
|
||||
except sqlite3.Error as e:
|
||||
self._conn.rollback()
|
||||
raise e
|
||||
finally:
|
||||
self._lock.release()
|
||||
# Set up and execute the count query, without pagination
|
||||
count_query += query_conditions + ";"
|
||||
count_params = query_params.copy()
|
||||
cursor.execute(count_query, count_params)
|
||||
count = cast(int, cursor.fetchone()[0])
|
||||
|
||||
return OffsetPaginatedResults(items=images, offset=offset, limit=limit, total=count)
|
||||
|
||||
def delete(self, image_name: str) -> None:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
DELETE FROM images
|
||||
WHERE image_name = ?;
|
||||
@@ -266,58 +243,48 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
|
||||
except sqlite3.Error as e:
|
||||
self._conn.rollback()
|
||||
raise ImageRecordDeleteException from e
|
||||
finally:
|
||||
self._lock.release()
|
||||
|
||||
def delete_many(self, image_names: list[str]) -> None:
|
||||
try:
|
||||
placeholders = ",".join("?" for _ in image_names)
|
||||
cursor = self._conn.cursor()
|
||||
|
||||
self._lock.acquire()
|
||||
placeholders = ",".join("?" for _ in image_names)
|
||||
|
||||
# Construct the SQLite query with the placeholders
|
||||
query = f"DELETE FROM images WHERE image_name IN ({placeholders})"
|
||||
|
||||
# Execute the query with the list of IDs as parameters
|
||||
self._cursor.execute(query, image_names)
|
||||
cursor.execute(query, image_names)
|
||||
|
||||
self._conn.commit()
|
||||
except sqlite3.Error as e:
|
||||
self._conn.rollback()
|
||||
raise ImageRecordDeleteException from e
|
||||
finally:
|
||||
self._lock.release()
|
||||
|
||||
def get_intermediates_count(self) -> int:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
"""--sql
|
||||
SELECT COUNT(*) FROM images
|
||||
WHERE is_intermediate = TRUE;
|
||||
"""
|
||||
)
|
||||
count = cast(int, self._cursor.fetchone()[0])
|
||||
self._conn.commit()
|
||||
return count
|
||||
except sqlite3.Error as e:
|
||||
self._conn.rollback()
|
||||
raise ImageRecordDeleteException from e
|
||||
finally:
|
||||
self._lock.release()
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT COUNT(*) FROM images
|
||||
WHERE is_intermediate = TRUE;
|
||||
"""
|
||||
)
|
||||
count = cast(int, cursor.fetchone()[0])
|
||||
self._conn.commit()
|
||||
return count
|
||||
|
||||
def delete_intermediates(self) -> list[str]:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT image_name FROM images
|
||||
WHERE is_intermediate = TRUE;
|
||||
"""
|
||||
)
|
||||
result = cast(list[sqlite3.Row], self._cursor.fetchall())
|
||||
result = cast(list[sqlite3.Row], cursor.fetchall())
|
||||
image_names = [r[0] for r in result]
|
||||
self._cursor.execute(
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
DELETE FROM images
|
||||
WHERE is_intermediate = TRUE;
|
||||
@@ -328,8 +295,6 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
|
||||
except sqlite3.Error as e:
|
||||
self._conn.rollback()
|
||||
raise ImageRecordDeleteException from e
|
||||
finally:
|
||||
self._lock.release()
|
||||
|
||||
def save(
|
||||
self,
|
||||
@@ -346,8 +311,8 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
|
||||
metadata: Optional[str] = None,
|
||||
) -> datetime:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT OR IGNORE INTO images (
|
||||
image_name,
|
||||
@@ -380,7 +345,7 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
self._cursor.execute(
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT created_at
|
||||
FROM images
|
||||
@@ -389,34 +354,30 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
|
||||
(image_name,),
|
||||
)
|
||||
|
||||
created_at = datetime.fromisoformat(self._cursor.fetchone()[0])
|
||||
created_at = datetime.fromisoformat(cursor.fetchone()[0])
|
||||
|
||||
return created_at
|
||||
except sqlite3.Error as e:
|
||||
self._conn.rollback()
|
||||
raise ImageRecordSaveException from e
|
||||
finally:
|
||||
self._lock.release()
|
||||
|
||||
def get_most_recent_image_for_board(self, board_id: str) -> Optional[ImageRecord]:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
"""--sql
|
||||
SELECT images.*
|
||||
FROM images
|
||||
JOIN board_images ON images.image_name = board_images.image_name
|
||||
WHERE board_images.board_id = ?
|
||||
AND images.is_intermediate = FALSE
|
||||
ORDER BY images.starred DESC, images.created_at DESC
|
||||
LIMIT 1;
|
||||
""",
|
||||
(board_id,),
|
||||
)
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT images.*
|
||||
FROM images
|
||||
JOIN board_images ON images.image_name = board_images.image_name
|
||||
WHERE board_images.board_id = ?
|
||||
AND images.is_intermediate = FALSE
|
||||
ORDER BY images.starred DESC, images.created_at DESC
|
||||
LIMIT 1;
|
||||
""",
|
||||
(board_id,),
|
||||
)
|
||||
|
||||
result = cast(Optional[sqlite3.Row], cursor.fetchone())
|
||||
|
||||
result = cast(Optional[sqlite3.Row], self._cursor.fetchone())
|
||||
finally:
|
||||
self._lock.release()
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
|
||||
@@ -265,7 +265,11 @@ class ImageService(ImageServiceABC):
|
||||
|
||||
def delete_images_on_board(self, board_id: str):
|
||||
try:
|
||||
image_names = self.__invoker.services.board_image_records.get_all_board_image_names_for_board(board_id)
|
||||
image_names = self.__invoker.services.board_image_records.get_all_board_image_names_for_board(
|
||||
board_id,
|
||||
categories=None,
|
||||
is_intermediate=None,
|
||||
)
|
||||
for image_name in image_names:
|
||||
self.__invoker.services.image_files.delete(image_name)
|
||||
self.__invoker.services.image_records.delete_many(image_names)
|
||||
@@ -278,7 +282,7 @@ class ImageService(ImageServiceABC):
|
||||
self.__invoker.services.logger.error("Failed to delete image files")
|
||||
raise
|
||||
except Exception as e:
|
||||
self.__invoker.services.logger.error("Problem deleting image records and files")
|
||||
self.__invoker.services.logger.error(f"Problem deleting image records and files: {str(e)}")
|
||||
raise e
|
||||
|
||||
def delete_intermediates(self) -> int:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# TODO: Should these excpetions subclass existing python exceptions?
|
||||
# TODO: Should these exceptions subclass existing python exceptions?
|
||||
class ModelImageFileNotFoundException(Exception):
|
||||
"""Raised when an image file is not found in storage."""
|
||||
|
||||
|
||||
@@ -78,7 +78,6 @@ class ModelRecordServiceSQL(ModelRecordServiceBase):
|
||||
"""
|
||||
super().__init__()
|
||||
self._db = db
|
||||
self._cursor = db.conn.cursor()
|
||||
self._logger = logger
|
||||
|
||||
@property
|
||||
@@ -96,38 +95,38 @@ class ModelRecordServiceSQL(ModelRecordServiceBase):
|
||||
|
||||
Can raise DuplicateModelException and InvalidModelConfigException exceptions.
|
||||
"""
|
||||
with self._db.lock:
|
||||
try:
|
||||
self._cursor.execute(
|
||||
"""--sql
|
||||
INSERT INTO models (
|
||||
id,
|
||||
config
|
||||
)
|
||||
VALUES (?,?);
|
||||
""",
|
||||
(
|
||||
config.key,
|
||||
config.model_dump_json(),
|
||||
),
|
||||
)
|
||||
self._db.conn.commit()
|
||||
try:
|
||||
cursor = self._db.conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT INTO models (
|
||||
id,
|
||||
config
|
||||
)
|
||||
VALUES (?,?);
|
||||
""",
|
||||
(
|
||||
config.key,
|
||||
config.model_dump_json(),
|
||||
),
|
||||
)
|
||||
self._db.conn.commit()
|
||||
|
||||
except sqlite3.IntegrityError as e:
|
||||
self._db.conn.rollback()
|
||||
if "UNIQUE constraint failed" in str(e):
|
||||
if "models.path" in str(e):
|
||||
msg = f"A model with path '{config.path}' is already installed"
|
||||
elif "models.name" in str(e):
|
||||
msg = f"A model with name='{config.name}', type='{config.type}', base='{config.base}' is already installed"
|
||||
else:
|
||||
msg = f"A model with key '{config.key}' is already installed"
|
||||
raise DuplicateModelException(msg) from e
|
||||
except sqlite3.IntegrityError as e:
|
||||
self._db.conn.rollback()
|
||||
if "UNIQUE constraint failed" in str(e):
|
||||
if "models.path" in str(e):
|
||||
msg = f"A model with path '{config.path}' is already installed"
|
||||
elif "models.name" in str(e):
|
||||
msg = f"A model with name='{config.name}', type='{config.type}', base='{config.base}' is already installed"
|
||||
else:
|
||||
raise e
|
||||
except sqlite3.Error as e:
|
||||
self._db.conn.rollback()
|
||||
msg = f"A model with key '{config.key}' is already installed"
|
||||
raise DuplicateModelException(msg) from e
|
||||
else:
|
||||
raise e
|
||||
except sqlite3.Error as e:
|
||||
self._db.conn.rollback()
|
||||
raise e
|
||||
|
||||
return self.get_model(config.key)
|
||||
|
||||
@@ -139,21 +138,21 @@ class ModelRecordServiceSQL(ModelRecordServiceBase):
|
||||
|
||||
Can raise an UnknownModelException
|
||||
"""
|
||||
with self._db.lock:
|
||||
try:
|
||||
self._cursor.execute(
|
||||
"""--sql
|
||||
DELETE FROM models
|
||||
WHERE id=?;
|
||||
""",
|
||||
(key,),
|
||||
)
|
||||
if self._cursor.rowcount == 0:
|
||||
raise UnknownModelException("model not found")
|
||||
self._db.conn.commit()
|
||||
except sqlite3.Error as e:
|
||||
self._db.conn.rollback()
|
||||
raise e
|
||||
try:
|
||||
cursor = self._db.conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
DELETE FROM models
|
||||
WHERE id=?;
|
||||
""",
|
||||
(key,),
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
raise UnknownModelException("model not found")
|
||||
self._db.conn.commit()
|
||||
except sqlite3.Error as e:
|
||||
self._db.conn.rollback()
|
||||
raise e
|
||||
|
||||
def update_model(self, key: str, changes: ModelRecordChanges) -> AnyModelConfig:
|
||||
record = self.get_model(key)
|
||||
@@ -164,23 +163,23 @@ class ModelRecordServiceSQL(ModelRecordServiceBase):
|
||||
|
||||
json_serialized = record.model_dump_json()
|
||||
|
||||
with self._db.lock:
|
||||
try:
|
||||
self._cursor.execute(
|
||||
"""--sql
|
||||
UPDATE models
|
||||
SET
|
||||
config=?
|
||||
WHERE id=?;
|
||||
""",
|
||||
(json_serialized, key),
|
||||
)
|
||||
if self._cursor.rowcount == 0:
|
||||
raise UnknownModelException("model not found")
|
||||
self._db.conn.commit()
|
||||
except sqlite3.Error as e:
|
||||
self._db.conn.rollback()
|
||||
raise e
|
||||
try:
|
||||
cursor = self._db.conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
UPDATE models
|
||||
SET
|
||||
config=?
|
||||
WHERE id=?;
|
||||
""",
|
||||
(json_serialized, key),
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
raise UnknownModelException("model not found")
|
||||
self._db.conn.commit()
|
||||
except sqlite3.Error as e:
|
||||
self._db.conn.rollback()
|
||||
raise e
|
||||
|
||||
return self.get_model(key)
|
||||
|
||||
@@ -192,33 +191,33 @@ class ModelRecordServiceSQL(ModelRecordServiceBase):
|
||||
|
||||
Exceptions: UnknownModelException
|
||||
"""
|
||||
with self._db.lock:
|
||||
self._cursor.execute(
|
||||
"""--sql
|
||||
SELECT config, strftime('%s',updated_at) FROM models
|
||||
WHERE id=?;
|
||||
""",
|
||||
(key,),
|
||||
)
|
||||
rows = self._cursor.fetchone()
|
||||
if not rows:
|
||||
raise UnknownModelException("model not found")
|
||||
model = ModelConfigFactory.make_config(json.loads(rows[0]), timestamp=rows[1])
|
||||
cursor = self._db.conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT config, strftime('%s',updated_at) FROM models
|
||||
WHERE id=?;
|
||||
""",
|
||||
(key,),
|
||||
)
|
||||
rows = cursor.fetchone()
|
||||
if not rows:
|
||||
raise UnknownModelException("model not found")
|
||||
model = ModelConfigFactory.make_config(json.loads(rows[0]), timestamp=rows[1])
|
||||
return model
|
||||
|
||||
def get_model_by_hash(self, hash: str) -> AnyModelConfig:
|
||||
with self._db.lock:
|
||||
self._cursor.execute(
|
||||
"""--sql
|
||||
SELECT config, strftime('%s',updated_at) FROM models
|
||||
WHERE hash=?;
|
||||
""",
|
||||
(hash,),
|
||||
)
|
||||
rows = self._cursor.fetchone()
|
||||
if not rows:
|
||||
raise UnknownModelException("model not found")
|
||||
model = ModelConfigFactory.make_config(json.loads(rows[0]), timestamp=rows[1])
|
||||
cursor = self._db.conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT config, strftime('%s',updated_at) FROM models
|
||||
WHERE hash=?;
|
||||
""",
|
||||
(hash,),
|
||||
)
|
||||
rows = cursor.fetchone()
|
||||
if not rows:
|
||||
raise UnknownModelException("model not found")
|
||||
model = ModelConfigFactory.make_config(json.loads(rows[0]), timestamp=rows[1])
|
||||
return model
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
@@ -227,16 +226,15 @@ class ModelRecordServiceSQL(ModelRecordServiceBase):
|
||||
|
||||
:param key: Unique key for the model to be deleted
|
||||
"""
|
||||
count = 0
|
||||
with self._db.lock:
|
||||
self._cursor.execute(
|
||||
"""--sql
|
||||
select count(*) FROM models
|
||||
WHERE id=?;
|
||||
""",
|
||||
(key,),
|
||||
)
|
||||
count = self._cursor.fetchone()[0]
|
||||
cursor = self._db.conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
select count(*) FROM models
|
||||
WHERE id=?;
|
||||
""",
|
||||
(key,),
|
||||
)
|
||||
count = cursor.fetchone()[0]
|
||||
return count > 0
|
||||
|
||||
def search_by_attr(
|
||||
@@ -284,17 +282,18 @@ class ModelRecordServiceSQL(ModelRecordServiceBase):
|
||||
where_clause.append("format=?")
|
||||
bindings.append(model_format)
|
||||
where = f"WHERE {' AND '.join(where_clause)}" if where_clause else ""
|
||||
with self._db.lock:
|
||||
self._cursor.execute(
|
||||
f"""--sql
|
||||
SELECT config, strftime('%s',updated_at)
|
||||
FROM models
|
||||
{where}
|
||||
ORDER BY {ordering[order_by]} -- using ? to bind doesn't work here for some reason;
|
||||
""",
|
||||
tuple(bindings),
|
||||
)
|
||||
result = self._cursor.fetchall()
|
||||
|
||||
cursor = self._db.conn.cursor()
|
||||
cursor.execute(
|
||||
f"""--sql
|
||||
SELECT config, strftime('%s',updated_at)
|
||||
FROM models
|
||||
{where}
|
||||
ORDER BY {ordering[order_by]} -- using ? to bind doesn't work here for some reason;
|
||||
""",
|
||||
tuple(bindings),
|
||||
)
|
||||
result = cursor.fetchall()
|
||||
|
||||
# Parse the model configs.
|
||||
results: list[AnyModelConfig] = []
|
||||
@@ -313,34 +312,28 @@ class ModelRecordServiceSQL(ModelRecordServiceBase):
|
||||
|
||||
def search_by_path(self, path: Union[str, Path]) -> List[AnyModelConfig]:
|
||||
"""Return models with the indicated path."""
|
||||
results = []
|
||||
with self._db.lock:
|
||||
self._cursor.execute(
|
||||
"""--sql
|
||||
SELECT config, strftime('%s',updated_at) FROM models
|
||||
WHERE path=?;
|
||||
""",
|
||||
(str(path),),
|
||||
)
|
||||
results = [
|
||||
ModelConfigFactory.make_config(json.loads(x[0]), timestamp=x[1]) for x in self._cursor.fetchall()
|
||||
]
|
||||
cursor = self._db.conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT config, strftime('%s',updated_at) FROM models
|
||||
WHERE path=?;
|
||||
""",
|
||||
(str(path),),
|
||||
)
|
||||
results = [ModelConfigFactory.make_config(json.loads(x[0]), timestamp=x[1]) for x in cursor.fetchall()]
|
||||
return results
|
||||
|
||||
def search_by_hash(self, hash: str) -> List[AnyModelConfig]:
|
||||
"""Return models with the indicated hash."""
|
||||
results = []
|
||||
with self._db.lock:
|
||||
self._cursor.execute(
|
||||
"""--sql
|
||||
SELECT config, strftime('%s',updated_at) FROM models
|
||||
WHERE hash=?;
|
||||
""",
|
||||
(hash,),
|
||||
)
|
||||
results = [
|
||||
ModelConfigFactory.make_config(json.loads(x[0]), timestamp=x[1]) for x in self._cursor.fetchall()
|
||||
]
|
||||
cursor = self._db.conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT config, strftime('%s',updated_at) FROM models
|
||||
WHERE hash=?;
|
||||
""",
|
||||
(hash,),
|
||||
)
|
||||
results = [ModelConfigFactory.make_config(json.loads(x[0]), timestamp=x[1]) for x in cursor.fetchall()]
|
||||
return results
|
||||
|
||||
def list_models(
|
||||
@@ -356,33 +349,32 @@ class ModelRecordServiceSQL(ModelRecordServiceBase):
|
||||
ModelRecordOrderBy.Format: "format",
|
||||
}
|
||||
|
||||
# Lock so that the database isn't updated while we're doing the two queries.
|
||||
with self._db.lock:
|
||||
# query1: get the total number of model configs
|
||||
self._cursor.execute(
|
||||
"""--sql
|
||||
select count(*) from models;
|
||||
""",
|
||||
(),
|
||||
)
|
||||
total = int(self._cursor.fetchone()[0])
|
||||
cursor = self._db.conn.cursor()
|
||||
|
||||
# query2: fetch key fields
|
||||
self._cursor.execute(
|
||||
f"""--sql
|
||||
SELECT config
|
||||
FROM models
|
||||
ORDER BY {ordering[order_by]} -- using ? to bind doesn't work here for some reason
|
||||
LIMIT ?
|
||||
OFFSET ?;
|
||||
""",
|
||||
(
|
||||
per_page,
|
||||
page * per_page,
|
||||
),
|
||||
)
|
||||
rows = self._cursor.fetchall()
|
||||
items = [ModelSummary.model_validate(dict(x)) for x in rows]
|
||||
return PaginatedResults(
|
||||
page=page, pages=ceil(total / per_page), per_page=per_page, total=total, items=items
|
||||
)
|
||||
# Lock so that the database isn't updated while we're doing the two queries.
|
||||
# query1: get the total number of model configs
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
select count(*) from models;
|
||||
""",
|
||||
(),
|
||||
)
|
||||
total = int(cursor.fetchone()[0])
|
||||
|
||||
# query2: fetch key fields
|
||||
cursor.execute(
|
||||
f"""--sql
|
||||
SELECT config
|
||||
FROM models
|
||||
ORDER BY {ordering[order_by]} -- using ? to bind doesn't work here for some reason
|
||||
LIMIT ?
|
||||
OFFSET ?;
|
||||
""",
|
||||
(
|
||||
per_page,
|
||||
page * per_page,
|
||||
),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
items = [ModelSummary.model_validate(dict(x)) for x in rows]
|
||||
return PaginatedResults(page=page, pages=ceil(total / per_page), per_page=per_page, total=total, items=items)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
from typing import Any, Coroutine, Optional
|
||||
|
||||
from invokeai.app.services.session_queue.session_queue_common import (
|
||||
QUEUE_ITEM_STATUS,
|
||||
@@ -14,6 +14,7 @@ from invokeai.app.services.session_queue.session_queue_common import (
|
||||
IsEmptyResult,
|
||||
IsFullResult,
|
||||
PruneResult,
|
||||
RetryItemsResult,
|
||||
SessionQueueCountsByDestination,
|
||||
SessionQueueItem,
|
||||
SessionQueueItemDTO,
|
||||
@@ -32,7 +33,7 @@ class SessionQueueBase(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def enqueue_batch(self, queue_id: str, batch: Batch, prepend: bool) -> EnqueueBatchResult:
|
||||
def enqueue_batch(self, queue_id: str, batch: Batch, prepend: bool) -> Coroutine[Any, Any, EnqueueBatchResult]:
|
||||
"""Enqueues all permutations of a batch for execution."""
|
||||
pass
|
||||
|
||||
@@ -139,3 +140,8 @@ class SessionQueueBase(ABC):
|
||||
def set_queue_item_session(self, item_id: int, session: GraphExecutionState) -> SessionQueueItem:
|
||||
"""Sets the session for a session queue item. Use this to update the session state."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def retry_items_by_id(self, queue_id: str, item_ids: list[int]) -> RetryItemsResult:
|
||||
"""Retries the given queue items"""
|
||||
pass
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime
|
||||
import json
|
||||
from itertools import chain, product
|
||||
from typing import Generator, Iterable, Literal, NamedTuple, Optional, TypeAlias, Union, cast
|
||||
from typing import Generator, Literal, Optional, TypeAlias, Union, cast
|
||||
|
||||
from pydantic import (
|
||||
AliasChoices,
|
||||
@@ -234,6 +234,9 @@ class SessionQueueItemWithoutGraph(BaseModel):
|
||||
field_values: Optional[list[NodeFieldValue]] = Field(
|
||||
default=None, description="The field values that were used for this queue item"
|
||||
)
|
||||
retried_from_item_id: Optional[int] = Field(
|
||||
default=None, description="The item_id of the queue item that this item was retried from"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def queue_item_dto_from_dict(cls, queue_item_dict: dict) -> "SessionQueueItemDTO":
|
||||
@@ -344,6 +347,11 @@ class EnqueueBatchResult(BaseModel):
|
||||
priority: int = Field(description="The priority of the enqueued batch")
|
||||
|
||||
|
||||
class RetryItemsResult(BaseModel):
|
||||
queue_id: str = Field(description="The ID of the queue")
|
||||
retried_item_ids: list[int] = Field(description="The IDs of the queue items that were retried")
|
||||
|
||||
|
||||
class ClearResult(BaseModel):
|
||||
"""Result of clearing the session queue"""
|
||||
|
||||
@@ -398,61 +406,143 @@ class IsFullResult(BaseModel):
|
||||
# region Util
|
||||
|
||||
|
||||
def populate_graph(graph: Graph, node_field_values: Iterable[NodeFieldValue]) -> Graph:
|
||||
def create_session_nfv_tuples(batch: Batch, maximum: int) -> Generator[tuple[str, str, str], None, None]:
|
||||
"""
|
||||
Populates the given graph with the given batch data items.
|
||||
"""
|
||||
graph_clone = graph.model_copy(deep=True)
|
||||
for item in node_field_values:
|
||||
node = graph_clone.get_node(item.node_path)
|
||||
if node is None:
|
||||
continue
|
||||
setattr(node, item.field_name, item.value)
|
||||
graph_clone.update_node(item.node_path, node)
|
||||
return graph_clone
|
||||
Given a batch and a maximum number of sessions to create, generate a tuple of session_id, session_json, and
|
||||
field_values_json for each session.
|
||||
|
||||
The batch has a "source" graph and a data property. The data property is a list of lists of BatchDatum objects.
|
||||
Each BatchDatum has a field identifier (e.g. a node id and field name), and a list of values to substitute into
|
||||
the field.
|
||||
|
||||
def create_session_nfv_tuples(
|
||||
batch: Batch, maximum: int
|
||||
) -> Generator[tuple[GraphExecutionState, list[NodeFieldValue], Optional[WorkflowWithoutID]], None, None]:
|
||||
"""
|
||||
Create all graph permutations from the given batch data and graph. Yields tuples
|
||||
of the form (graph, batch_data_items) where batch_data_items is the list of BatchDataItems
|
||||
that was applied to the graph.
|
||||
This structure allows us to create a new graph for every possible permutation of BatchDatum objects:
|
||||
- Each BatchDatum can be "expanded" into a dict of node-field-value tuples - one for each item in the BatchDatum.
|
||||
- Zip each inner list of expanded BatchDatum objects together. Call this a "batch_data_list".
|
||||
- Take the cartesian product of all zipped batch_data_lists, resulting in a list of permutations of BatchDatum
|
||||
- Take the cartesian product of all zipped batch_data_lists, resulting in a list of lists of BatchDatum objects.
|
||||
Each inner list now represents the substitution values for a single permutation (session).
|
||||
- For each permutation, substitute the values into the graph
|
||||
|
||||
This function is optimized for performance, as it is used to generate a large number of sessions at once.
|
||||
|
||||
Args:
|
||||
batch: The batch to generate sessions from
|
||||
maximum: The maximum number of sessions to generate
|
||||
|
||||
Returns:
|
||||
A generator that yields tuples of session_id, session_json, and field_values_json for each session. The
|
||||
generator will stop early if the maximum number of sessions is reached.
|
||||
"""
|
||||
|
||||
# TODO: Should this be a class method on Batch?
|
||||
|
||||
data: list[list[tuple[NodeFieldValue]]] = []
|
||||
data: list[list[tuple[dict]]] = []
|
||||
batch_data_collection = batch.data if batch.data is not None else []
|
||||
for batch_datum_list in batch_data_collection:
|
||||
# each batch_datum_list needs to be convered to NodeFieldValues and then zipped
|
||||
|
||||
node_field_values_to_zip: list[list[NodeFieldValue]] = []
|
||||
for batch_datum_list in batch_data_collection:
|
||||
node_field_values_to_zip: list[list[dict]] = []
|
||||
# Expand each BatchDatum into a list of dicts - one for each item in the BatchDatum
|
||||
for batch_datum in batch_datum_list:
|
||||
node_field_values = [
|
||||
NodeFieldValue(node_path=batch_datum.node_path, field_name=batch_datum.field_name, value=item)
|
||||
# Note: A tuple here is slightly faster than a dict, but we need the object in dict form to be inserted
|
||||
# in the session_queue table anyways. So, overall creating NFVs as dicts is faster.
|
||||
{"node_path": batch_datum.node_path, "field_name": batch_datum.field_name, "value": item}
|
||||
for item in batch_datum.items
|
||||
]
|
||||
node_field_values_to_zip.append(node_field_values)
|
||||
# Zip the dicts together to create a list of dicts for each permutation
|
||||
data.append(list(zip(*node_field_values_to_zip, strict=True))) # type: ignore [arg-type]
|
||||
|
||||
# create generator to yield session,nfv tuples
|
||||
# We serialize the graph and session once, then mutate the graph dict in place for each session.
|
||||
#
|
||||
# This sounds scary, but it's actually fine.
|
||||
#
|
||||
# The batch prep logic injects field values into the same fields for each generated session.
|
||||
#
|
||||
# For example, after the product operation, we'll end up with a list of node-field-value tuples like this:
|
||||
# [
|
||||
# (
|
||||
# {"node_path": "1", "field_name": "a", "value": 1},
|
||||
# {"node_path": "2", "field_name": "b", "value": 2},
|
||||
# {"node_path": "3", "field_name": "c", "value": 3},
|
||||
# ),
|
||||
# (
|
||||
# {"node_path": "1", "field_name": "a", "value": 4},
|
||||
# {"node_path": "2", "field_name": "b", "value": 5},
|
||||
# {"node_path": "3", "field_name": "c", "value": 6},
|
||||
# )
|
||||
# ]
|
||||
#
|
||||
# Note that each tuple has the same length, and each tuple substitutes values in for exactly the same node fields.
|
||||
# No matter the complexity of the batch, this property holds true.
|
||||
#
|
||||
# This means each permutation's substitution can be done in-place on the same graph dict, because it overwrites the
|
||||
# previous mutation. We only need to serialize the graph once, and then we can mutate it in place for each session.
|
||||
#
|
||||
# Previously, we had created new Graph objects for each session, but this was very slow for large (1k+ session
|
||||
# batches). We then tried dumping the graph to dict and using deep-copy to create a new dict for each session,
|
||||
# but this was also slow.
|
||||
#
|
||||
# Overall, we achieved a 100x speedup by mutating the graph dict in place for each session over creating new Graph
|
||||
# objects for each session.
|
||||
#
|
||||
# We will also mutate the session dict in place, setting a new ID for each session and setting the mutated graph
|
||||
# dict as the session's graph.
|
||||
|
||||
# Dump the batch's graph to a dict once
|
||||
graph_as_dict = batch.graph.model_dump(warnings=False, exclude_none=True)
|
||||
|
||||
# We must provide a Graph object when creating the "dummy" session dict, but we don't actually use it. It will be
|
||||
# overwritten for each session by the mutated graph_as_dict.
|
||||
session_dict = GraphExecutionState(graph=Graph()).model_dump(warnings=False, exclude_none=True)
|
||||
|
||||
# Now we can create a generator that yields the session_id, session_json, and field_values_json for each session.
|
||||
count = 0
|
||||
|
||||
# Each batch may have multiple runs, so we need to generate the same number of sessions for each run. The total is
|
||||
# still limited by the maximum number of sessions.
|
||||
for _ in range(batch.runs):
|
||||
for d in product(*data):
|
||||
if count >= maximum:
|
||||
# We've reached the maximum number of sessions we may generate
|
||||
return
|
||||
|
||||
# Flatten the list of lists of dicts into a single list of dicts
|
||||
# TODO(psyche): Is the a more efficient way to do this?
|
||||
flat_node_field_values = list(chain.from_iterable(d))
|
||||
graph = populate_graph(batch.graph, flat_node_field_values)
|
||||
yield (GraphExecutionState(graph=graph), flat_node_field_values, batch.workflow)
|
||||
|
||||
# Need a fresh ID for each session
|
||||
session_id = uuid_string()
|
||||
|
||||
# Mutate the session dict in place
|
||||
session_dict["id"] = session_id
|
||||
|
||||
# Substitute the values into the graph
|
||||
for nfv in flat_node_field_values:
|
||||
graph_as_dict["nodes"][nfv["node_path"]][nfv["field_name"]] = nfv["value"]
|
||||
|
||||
# Mutate the session dict in place
|
||||
session_dict["graph"] = graph_as_dict
|
||||
|
||||
# Serialize the session and field values
|
||||
# Note the use of pydantic's to_jsonable_python to handle serialization of any python object, including sets.
|
||||
session_json = json.dumps(session_dict, default=to_jsonable_python)
|
||||
field_values_json = json.dumps(flat_node_field_values, default=to_jsonable_python)
|
||||
|
||||
# Yield the session_id, session_json, and field_values_json
|
||||
yield (session_id, session_json, field_values_json)
|
||||
|
||||
# Increment the count so we know when to stop
|
||||
count += 1
|
||||
|
||||
|
||||
def calc_session_count(batch: Batch) -> int:
|
||||
"""
|
||||
Calculates the number of sessions that would be created by the batch, without incurring
|
||||
the overhead of actually generating them. Adapted from `create_sessions().
|
||||
Calculates the number of sessions that would be created by the batch, without incurring the overhead of actually
|
||||
creating them, as is done in `create_session_nfv_tuples()`.
|
||||
|
||||
The count is used to communicate to the user how many sessions were _requested_ to be created, as opposed to how
|
||||
many were _actually_ created (which may be less due to the maximum number of sessions).
|
||||
"""
|
||||
# TODO: Should this be a class method on Batch?
|
||||
if not batch.data:
|
||||
@@ -468,41 +558,75 @@ def calc_session_count(batch: Batch) -> int:
|
||||
return len(data_product) * batch.runs
|
||||
|
||||
|
||||
class SessionQueueValueToInsert(NamedTuple):
|
||||
"""A tuple of values to insert into the session_queue table"""
|
||||
|
||||
# Careful with the ordering of this - it must match the insert statement
|
||||
queue_id: str # queue_id
|
||||
session: str # session json
|
||||
session_id: str # session_id
|
||||
batch_id: str # batch_id
|
||||
field_values: Optional[str] # field_values json
|
||||
priority: int # priority
|
||||
workflow: Optional[str] # workflow json
|
||||
origin: str | None
|
||||
destination: str | None
|
||||
ValueToInsertTuple: TypeAlias = tuple[
|
||||
str, # queue_id
|
||||
str, # session (as stringified JSON)
|
||||
str, # session_id
|
||||
str, # batch_id
|
||||
str | None, # field_values (optional, as stringified JSON)
|
||||
int, # priority
|
||||
str | None, # workflow (optional, as stringified JSON)
|
||||
str | None, # origin (optional)
|
||||
str | None, # destination (optional)
|
||||
int | None, # retried_from_item_id (optional, this is always None for new items)
|
||||
]
|
||||
"""A type alias for the tuple of values to insert into the session queue table."""
|
||||
|
||||
|
||||
ValuesToInsert: TypeAlias = list[SessionQueueValueToInsert]
|
||||
def prepare_values_to_insert(
|
||||
queue_id: str, batch: Batch, priority: int, max_new_queue_items: int
|
||||
) -> list[ValueToInsertTuple]:
|
||||
"""
|
||||
Given a batch, prepare the values to insert into the session queue table. The list of tuples can be used with an
|
||||
`executemany` statement to insert multiple rows at once.
|
||||
|
||||
Args:
|
||||
queue_id: The ID of the queue to insert the items into
|
||||
batch: The batch to prepare the values for
|
||||
priority: The priority of the queue items
|
||||
max_new_queue_items: The maximum number of queue items to insert
|
||||
|
||||
def prepare_values_to_insert(queue_id: str, batch: Batch, priority: int, max_new_queue_items: int) -> ValuesToInsert:
|
||||
values_to_insert: ValuesToInsert = []
|
||||
for session, field_values, workflow in create_session_nfv_tuples(batch, max_new_queue_items):
|
||||
# sessions must have unique id
|
||||
session.id = uuid_string()
|
||||
Returns:
|
||||
A list of tuples to insert into the session queue table. Each tuple contains the following values:
|
||||
- queue_id
|
||||
- session (as stringified JSON)
|
||||
- session_id
|
||||
- batch_id
|
||||
- field_values (optional, as stringified JSON)
|
||||
- priority
|
||||
- workflow (optional, as stringified JSON)
|
||||
- origin (optional)
|
||||
- destination (optional)
|
||||
- retried_from_item_id (optional, this is always None for new items)
|
||||
"""
|
||||
|
||||
# A tuple is a fast and memory-efficient way to store the values to insert. Previously, we used a NamedTuple, but
|
||||
# measured a ~5% performance improvement by using a normal tuple instead. For very large batches (10k+ items), the
|
||||
# this difference becomes noticeable.
|
||||
#
|
||||
# So, despite the inferior DX with normal tuples, we use one here for performance reasons.
|
||||
|
||||
values_to_insert: list[ValueToInsertTuple] = []
|
||||
|
||||
# pydantic's to_jsonable_python handles serialization of any python object, including sets, which json.dumps does
|
||||
# not support by default. Apparently there are sets somewhere in the graph.
|
||||
|
||||
# The same workflow is used for all sessions in the batch - serialize it once
|
||||
workflow_json = json.dumps(batch.workflow, default=to_jsonable_python) if batch.workflow else None
|
||||
|
||||
for session_id, session_json, field_values_json in create_session_nfv_tuples(batch, max_new_queue_items):
|
||||
values_to_insert.append(
|
||||
SessionQueueValueToInsert(
|
||||
queue_id, # queue_id
|
||||
session.model_dump_json(warnings=False, exclude_none=True), # session (json)
|
||||
session.id, # session_id
|
||||
batch.batch_id, # batch_id
|
||||
# must use pydantic_encoder bc field_values is a list of models
|
||||
json.dumps(field_values, default=to_jsonable_python) if field_values else None, # field_values (json)
|
||||
priority, # priority
|
||||
json.dumps(workflow, default=to_jsonable_python) if workflow else None, # workflow (json)
|
||||
batch.origin, # origin
|
||||
batch.destination, # destination
|
||||
(
|
||||
queue_id,
|
||||
session_json,
|
||||
session_id,
|
||||
batch.batch_id,
|
||||
field_values_json,
|
||||
priority,
|
||||
workflow_json,
|
||||
batch.origin,
|
||||
batch.destination,
|
||||
None,
|
||||
)
|
||||
)
|
||||
return values_to_insert
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import asyncio
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import Optional, Union, cast
|
||||
|
||||
from pydantic_core import to_jsonable_python
|
||||
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.session_queue.session_queue_base import SessionQueueBase
|
||||
from invokeai.app.services.session_queue.session_queue_common import (
|
||||
@@ -18,6 +21,7 @@ from invokeai.app.services.session_queue.session_queue_common import (
|
||||
IsEmptyResult,
|
||||
IsFullResult,
|
||||
PruneResult,
|
||||
RetryItemsResult,
|
||||
SessionQueueCountsByDestination,
|
||||
SessionQueueItem,
|
||||
SessionQueueItemDTO,
|
||||
@@ -33,9 +37,6 @@ from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
|
||||
|
||||
class SqliteSessionQueue(SessionQueueBase):
|
||||
__invoker: Invoker
|
||||
__conn: sqlite3.Connection
|
||||
__cursor: sqlite3.Cursor
|
||||
__lock: threading.RLock
|
||||
|
||||
def start(self, invoker: Invoker) -> None:
|
||||
self.__invoker = invoker
|
||||
@@ -51,9 +52,7 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
|
||||
def __init__(self, db: SqliteDatabase) -> None:
|
||||
super().__init__()
|
||||
self.__lock = db.lock
|
||||
self.__conn = db.conn
|
||||
self.__cursor = self.__conn.cursor()
|
||||
self._conn = db.conn
|
||||
|
||||
def _set_in_progress_to_canceled(self) -> None:
|
||||
"""
|
||||
@@ -61,8 +60,8 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
This is necessary because the invoker may have been killed while processing a queue item.
|
||||
"""
|
||||
try:
|
||||
self.__lock.acquire()
|
||||
self.__cursor.execute(
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
UPDATE session_queue
|
||||
SET status = 'canceled'
|
||||
@@ -70,14 +69,13 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
"""
|
||||
)
|
||||
except Exception:
|
||||
self.__conn.rollback()
|
||||
self._conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self.__lock.release()
|
||||
|
||||
def _get_current_queue_size(self, queue_id: str) -> int:
|
||||
"""Gets the current number of pending queue items"""
|
||||
self.__cursor.execute(
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT count(*)
|
||||
FROM session_queue
|
||||
@@ -87,11 +85,12 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
""",
|
||||
(queue_id,),
|
||||
)
|
||||
return cast(int, self.__cursor.fetchone()[0])
|
||||
return cast(int, cursor.fetchone()[0])
|
||||
|
||||
def _get_highest_priority(self, queue_id: str) -> int:
|
||||
"""Gets the highest priority value in the queue"""
|
||||
self.__cursor.execute(
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT MAX(priority)
|
||||
FROM session_queue
|
||||
@@ -101,12 +100,14 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
""",
|
||||
(queue_id,),
|
||||
)
|
||||
return cast(Union[int, None], self.__cursor.fetchone()[0]) or 0
|
||||
return cast(Union[int, None], cursor.fetchone()[0]) or 0
|
||||
|
||||
def enqueue_batch(self, queue_id: str, batch: Batch, prepend: bool) -> EnqueueBatchResult:
|
||||
async def enqueue_batch(self, queue_id: str, batch: Batch, prepend: bool) -> EnqueueBatchResult:
|
||||
return await asyncio.to_thread(self._enqueue_batch, queue_id, batch, prepend)
|
||||
|
||||
def _enqueue_batch(self, queue_id: str, batch: Batch, prepend: bool) -> EnqueueBatchResult:
|
||||
try:
|
||||
self.__lock.acquire()
|
||||
|
||||
cursor = self._conn.cursor()
|
||||
# TODO: how does this work in a multi-user scenario?
|
||||
current_queue_size = self._get_current_queue_size(queue_id)
|
||||
max_queue_size = self.__invoker.services.configuration.max_queue_size
|
||||
@@ -128,19 +129,17 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
if requested_count > enqueued_count:
|
||||
values_to_insert = values_to_insert[:max_new_queue_items]
|
||||
|
||||
self.__cursor.executemany(
|
||||
cursor.executemany(
|
||||
"""--sql
|
||||
INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
values_to_insert,
|
||||
)
|
||||
self.__conn.commit()
|
||||
self._conn.commit()
|
||||
except Exception:
|
||||
self.__conn.rollback()
|
||||
self._conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self.__lock.release()
|
||||
enqueue_result = EnqueueBatchResult(
|
||||
queue_id=queue_id,
|
||||
requested=requested_count,
|
||||
@@ -152,25 +151,19 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
return enqueue_result
|
||||
|
||||
def dequeue(self) -> Optional[SessionQueueItem]:
|
||||
try:
|
||||
self.__lock.acquire()
|
||||
self.__cursor.execute(
|
||||
"""--sql
|
||||
SELECT *
|
||||
FROM session_queue
|
||||
WHERE status = 'pending'
|
||||
ORDER BY
|
||||
priority DESC,
|
||||
item_id ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
result = cast(Union[sqlite3.Row, None], self.__cursor.fetchone())
|
||||
except Exception:
|
||||
self.__conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self.__lock.release()
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT *
|
||||
FROM session_queue
|
||||
WHERE status = 'pending'
|
||||
ORDER BY
|
||||
priority DESC,
|
||||
item_id ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
result = cast(Union[sqlite3.Row, None], cursor.fetchone())
|
||||
if result is None:
|
||||
return None
|
||||
queue_item = SessionQueueItem.queue_item_from_dict(dict(result))
|
||||
@@ -178,52 +171,40 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
return queue_item
|
||||
|
||||
def get_next(self, queue_id: str) -> Optional[SessionQueueItem]:
|
||||
try:
|
||||
self.__lock.acquire()
|
||||
self.__cursor.execute(
|
||||
"""--sql
|
||||
SELECT *
|
||||
FROM session_queue
|
||||
WHERE
|
||||
queue_id = ?
|
||||
AND status = 'pending'
|
||||
ORDER BY
|
||||
priority DESC,
|
||||
created_at ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(queue_id,),
|
||||
)
|
||||
result = cast(Union[sqlite3.Row, None], self.__cursor.fetchone())
|
||||
except Exception:
|
||||
self.__conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self.__lock.release()
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT *
|
||||
FROM session_queue
|
||||
WHERE
|
||||
queue_id = ?
|
||||
AND status = 'pending'
|
||||
ORDER BY
|
||||
priority DESC,
|
||||
created_at ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(queue_id,),
|
||||
)
|
||||
result = cast(Union[sqlite3.Row, None], cursor.fetchone())
|
||||
if result is None:
|
||||
return None
|
||||
return SessionQueueItem.queue_item_from_dict(dict(result))
|
||||
|
||||
def get_current(self, queue_id: str) -> Optional[SessionQueueItem]:
|
||||
try:
|
||||
self.__lock.acquire()
|
||||
self.__cursor.execute(
|
||||
"""--sql
|
||||
SELECT *
|
||||
FROM session_queue
|
||||
WHERE
|
||||
queue_id = ?
|
||||
AND status = 'in_progress'
|
||||
LIMIT 1
|
||||
""",
|
||||
(queue_id,),
|
||||
)
|
||||
result = cast(Union[sqlite3.Row, None], self.__cursor.fetchone())
|
||||
except Exception:
|
||||
self.__conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self.__lock.release()
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT *
|
||||
FROM session_queue
|
||||
WHERE
|
||||
queue_id = ?
|
||||
AND status = 'in_progress'
|
||||
LIMIT 1
|
||||
""",
|
||||
(queue_id,),
|
||||
)
|
||||
result = cast(Union[sqlite3.Row, None], cursor.fetchone())
|
||||
if result is None:
|
||||
return None
|
||||
return SessionQueueItem.queue_item_from_dict(dict(result))
|
||||
@@ -237,8 +218,8 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
error_traceback: Optional[str] = None,
|
||||
) -> SessionQueueItem:
|
||||
try:
|
||||
self.__lock.acquire()
|
||||
self.__cursor.execute(
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
UPDATE session_queue
|
||||
SET status = ?, error_type = ?, error_message = ?, error_traceback = ?
|
||||
@@ -246,12 +227,10 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
""",
|
||||
(status, error_type, error_message, error_traceback, item_id),
|
||||
)
|
||||
self.__conn.commit()
|
||||
self._conn.commit()
|
||||
except Exception:
|
||||
self.__conn.rollback()
|
||||
self._conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self.__lock.release()
|
||||
queue_item = self.get_queue_item(item_id)
|
||||
batch_status = self.get_batch_status(queue_id=queue_item.queue_id, batch_id=queue_item.batch_id)
|
||||
queue_status = self.get_queue_status(queue_id=queue_item.queue_id)
|
||||
@@ -259,48 +238,36 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
return queue_item
|
||||
|
||||
def is_empty(self, queue_id: str) -> IsEmptyResult:
|
||||
try:
|
||||
self.__lock.acquire()
|
||||
self.__cursor.execute(
|
||||
"""--sql
|
||||
SELECT count(*)
|
||||
FROM session_queue
|
||||
WHERE queue_id = ?
|
||||
""",
|
||||
(queue_id,),
|
||||
)
|
||||
is_empty = cast(int, self.__cursor.fetchone()[0]) == 0
|
||||
except Exception:
|
||||
self.__conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self.__lock.release()
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT count(*)
|
||||
FROM session_queue
|
||||
WHERE queue_id = ?
|
||||
""",
|
||||
(queue_id,),
|
||||
)
|
||||
is_empty = cast(int, cursor.fetchone()[0]) == 0
|
||||
return IsEmptyResult(is_empty=is_empty)
|
||||
|
||||
def is_full(self, queue_id: str) -> IsFullResult:
|
||||
try:
|
||||
self.__lock.acquire()
|
||||
self.__cursor.execute(
|
||||
"""--sql
|
||||
SELECT count(*)
|
||||
FROM session_queue
|
||||
WHERE queue_id = ?
|
||||
""",
|
||||
(queue_id,),
|
||||
)
|
||||
max_queue_size = self.__invoker.services.configuration.max_queue_size
|
||||
is_full = cast(int, self.__cursor.fetchone()[0]) >= max_queue_size
|
||||
except Exception:
|
||||
self.__conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self.__lock.release()
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT count(*)
|
||||
FROM session_queue
|
||||
WHERE queue_id = ?
|
||||
""",
|
||||
(queue_id,),
|
||||
)
|
||||
max_queue_size = self.__invoker.services.configuration.max_queue_size
|
||||
is_full = cast(int, cursor.fetchone()[0]) >= max_queue_size
|
||||
return IsFullResult(is_full=is_full)
|
||||
|
||||
def clear(self, queue_id: str) -> ClearResult:
|
||||
try:
|
||||
self.__lock.acquire()
|
||||
self.__cursor.execute(
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT COUNT(*)
|
||||
FROM session_queue
|
||||
@@ -308,8 +275,8 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
""",
|
||||
(queue_id,),
|
||||
)
|
||||
count = self.__cursor.fetchone()[0]
|
||||
self.__cursor.execute(
|
||||
count = cursor.fetchone()[0]
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
DELETE
|
||||
FROM session_queue
|
||||
@@ -317,17 +284,16 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
""",
|
||||
(queue_id,),
|
||||
)
|
||||
self.__conn.commit()
|
||||
self._conn.commit()
|
||||
except Exception:
|
||||
self.__conn.rollback()
|
||||
self._conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self.__lock.release()
|
||||
self.__invoker.services.events.emit_queue_cleared(queue_id)
|
||||
return ClearResult(deleted=count)
|
||||
|
||||
def prune(self, queue_id: str) -> PruneResult:
|
||||
try:
|
||||
cursor = self._conn.cursor()
|
||||
where = """--sql
|
||||
WHERE
|
||||
queue_id = ?
|
||||
@@ -337,8 +303,7 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
OR status = 'canceled'
|
||||
)
|
||||
"""
|
||||
self.__lock.acquire()
|
||||
self.__cursor.execute(
|
||||
cursor.execute(
|
||||
f"""--sql
|
||||
SELECT COUNT(*)
|
||||
FROM session_queue
|
||||
@@ -346,8 +311,8 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
""",
|
||||
(queue_id,),
|
||||
)
|
||||
count = self.__cursor.fetchone()[0]
|
||||
self.__cursor.execute(
|
||||
count = cursor.fetchone()[0]
|
||||
cursor.execute(
|
||||
f"""--sql
|
||||
DELETE
|
||||
FROM session_queue
|
||||
@@ -355,12 +320,10 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
""",
|
||||
(queue_id,),
|
||||
)
|
||||
self.__conn.commit()
|
||||
self._conn.commit()
|
||||
except Exception:
|
||||
self.__conn.rollback()
|
||||
self._conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self.__lock.release()
|
||||
return PruneResult(deleted=count)
|
||||
|
||||
def cancel_queue_item(self, item_id: int) -> SessionQueueItem:
|
||||
@@ -389,8 +352,8 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
|
||||
def cancel_by_batch_ids(self, queue_id: str, batch_ids: list[str]) -> CancelByBatchIDsResult:
|
||||
try:
|
||||
cursor = self._conn.cursor()
|
||||
current_queue_item = self.get_current(queue_id)
|
||||
self.__lock.acquire()
|
||||
placeholders = ", ".join(["?" for _ in batch_ids])
|
||||
where = f"""--sql
|
||||
WHERE
|
||||
@@ -401,7 +364,7 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
AND status != 'failed'
|
||||
"""
|
||||
params = [queue_id] + batch_ids
|
||||
self.__cursor.execute(
|
||||
cursor.execute(
|
||||
f"""--sql
|
||||
SELECT COUNT(*)
|
||||
FROM session_queue
|
||||
@@ -409,8 +372,8 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
""",
|
||||
tuple(params),
|
||||
)
|
||||
count = self.__cursor.fetchone()[0]
|
||||
self.__cursor.execute(
|
||||
count = cursor.fetchone()[0]
|
||||
cursor.execute(
|
||||
f"""--sql
|
||||
UPDATE session_queue
|
||||
SET status = 'canceled'
|
||||
@@ -418,20 +381,18 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
""",
|
||||
tuple(params),
|
||||
)
|
||||
self.__conn.commit()
|
||||
self._conn.commit()
|
||||
if current_queue_item is not None and current_queue_item.batch_id in batch_ids:
|
||||
self._set_queue_item_status(current_queue_item.item_id, "canceled")
|
||||
except Exception:
|
||||
self.__conn.rollback()
|
||||
self._conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self.__lock.release()
|
||||
return CancelByBatchIDsResult(canceled=count)
|
||||
|
||||
def cancel_by_destination(self, queue_id: str, destination: str) -> CancelByDestinationResult:
|
||||
try:
|
||||
cursor = self._conn.cursor()
|
||||
current_queue_item = self.get_current(queue_id)
|
||||
self.__lock.acquire()
|
||||
where = """--sql
|
||||
WHERE
|
||||
queue_id == ?
|
||||
@@ -441,7 +402,7 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
AND status != 'failed'
|
||||
"""
|
||||
params = (queue_id, destination)
|
||||
self.__cursor.execute(
|
||||
cursor.execute(
|
||||
f"""--sql
|
||||
SELECT COUNT(*)
|
||||
FROM session_queue
|
||||
@@ -449,8 +410,8 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
""",
|
||||
params,
|
||||
)
|
||||
count = self.__cursor.fetchone()[0]
|
||||
self.__cursor.execute(
|
||||
count = cursor.fetchone()[0]
|
||||
cursor.execute(
|
||||
f"""--sql
|
||||
UPDATE session_queue
|
||||
SET status = 'canceled'
|
||||
@@ -458,20 +419,18 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
""",
|
||||
params,
|
||||
)
|
||||
self.__conn.commit()
|
||||
self._conn.commit()
|
||||
if current_queue_item is not None and current_queue_item.destination == destination:
|
||||
self._set_queue_item_status(current_queue_item.item_id, "canceled")
|
||||
except Exception:
|
||||
self.__conn.rollback()
|
||||
self._conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self.__lock.release()
|
||||
return CancelByDestinationResult(canceled=count)
|
||||
|
||||
def cancel_by_queue_id(self, queue_id: str) -> CancelByQueueIDResult:
|
||||
try:
|
||||
cursor = self._conn.cursor()
|
||||
current_queue_item = self.get_current(queue_id)
|
||||
self.__lock.acquire()
|
||||
where = """--sql
|
||||
WHERE
|
||||
queue_id is ?
|
||||
@@ -480,7 +439,7 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
AND status != 'failed'
|
||||
"""
|
||||
params = [queue_id]
|
||||
self.__cursor.execute(
|
||||
cursor.execute(
|
||||
f"""--sql
|
||||
SELECT COUNT(*)
|
||||
FROM session_queue
|
||||
@@ -488,8 +447,8 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
""",
|
||||
tuple(params),
|
||||
)
|
||||
count = self.__cursor.fetchone()[0]
|
||||
self.__cursor.execute(
|
||||
count = cursor.fetchone()[0]
|
||||
cursor.execute(
|
||||
f"""--sql
|
||||
UPDATE session_queue
|
||||
SET status = 'canceled'
|
||||
@@ -497,7 +456,7 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
""",
|
||||
tuple(params),
|
||||
)
|
||||
self.__conn.commit()
|
||||
self._conn.commit()
|
||||
if current_queue_item is not None and current_queue_item.queue_id == queue_id:
|
||||
batch_status = self.get_batch_status(queue_id=queue_id, batch_id=current_queue_item.batch_id)
|
||||
queue_status = self.get_queue_status(queue_id=queue_id)
|
||||
@@ -505,21 +464,19 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
current_queue_item, batch_status, queue_status
|
||||
)
|
||||
except Exception:
|
||||
self.__conn.rollback()
|
||||
self._conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self.__lock.release()
|
||||
return CancelByQueueIDResult(canceled=count)
|
||||
|
||||
def cancel_all_except_current(self, queue_id: str) -> CancelAllExceptCurrentResult:
|
||||
try:
|
||||
cursor = self._conn.cursor()
|
||||
where = """--sql
|
||||
WHERE
|
||||
queue_id == ?
|
||||
AND status == 'pending'
|
||||
"""
|
||||
self.__lock.acquire()
|
||||
self.__cursor.execute(
|
||||
cursor.execute(
|
||||
f"""--sql
|
||||
SELECT COUNT(*)
|
||||
FROM session_queue
|
||||
@@ -527,8 +484,8 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
""",
|
||||
(queue_id,),
|
||||
)
|
||||
count = self.__cursor.fetchone()[0]
|
||||
self.__cursor.execute(
|
||||
count = cursor.fetchone()[0]
|
||||
cursor.execute(
|
||||
f"""--sql
|
||||
UPDATE session_queue
|
||||
SET status = 'canceled'
|
||||
@@ -536,43 +493,35 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
""",
|
||||
(queue_id,),
|
||||
)
|
||||
self.__conn.commit()
|
||||
self._conn.commit()
|
||||
except Exception:
|
||||
self.__conn.rollback()
|
||||
self._conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self.__lock.release()
|
||||
return CancelAllExceptCurrentResult(canceled=count)
|
||||
|
||||
def get_queue_item(self, item_id: int) -> SessionQueueItem:
|
||||
try:
|
||||
self.__lock.acquire()
|
||||
self.__cursor.execute(
|
||||
"""--sql
|
||||
SELECT * FROM session_queue
|
||||
WHERE
|
||||
item_id = ?
|
||||
""",
|
||||
(item_id,),
|
||||
)
|
||||
result = cast(Union[sqlite3.Row, None], self.__cursor.fetchone())
|
||||
except Exception:
|
||||
self.__conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self.__lock.release()
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT * FROM session_queue
|
||||
WHERE
|
||||
item_id = ?
|
||||
""",
|
||||
(item_id,),
|
||||
)
|
||||
result = cast(Union[sqlite3.Row, None], cursor.fetchone())
|
||||
if result is None:
|
||||
raise SessionQueueItemNotFoundError(f"No queue item with id {item_id}")
|
||||
return SessionQueueItem.queue_item_from_dict(dict(result))
|
||||
|
||||
def set_queue_item_session(self, item_id: int, session: GraphExecutionState) -> SessionQueueItem:
|
||||
try:
|
||||
cursor = self._conn.cursor()
|
||||
# Use exclude_none so we don't end up with a bunch of nulls in the graph - this can cause validation errors
|
||||
# when the graph is loaded. Graph execution occurs purely in memory - the session saved here is not referenced
|
||||
# during execution.
|
||||
session_json = session.model_dump_json(warnings=False, exclude_none=True)
|
||||
self.__lock.acquire()
|
||||
self.__cursor.execute(
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
UPDATE session_queue
|
||||
SET session = ?
|
||||
@@ -580,12 +529,10 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
""",
|
||||
(session_json, item_id),
|
||||
)
|
||||
self.__conn.commit()
|
||||
self._conn.commit()
|
||||
except Exception:
|
||||
self.__conn.rollback()
|
||||
self._conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self.__lock.release()
|
||||
return self.get_queue_item(item_id)
|
||||
|
||||
def list_queue_items(
|
||||
@@ -596,83 +543,71 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
cursor: Optional[int] = None,
|
||||
status: Optional[QUEUE_ITEM_STATUS] = None,
|
||||
) -> CursorPaginatedResults[SessionQueueItemDTO]:
|
||||
try:
|
||||
item_id = cursor
|
||||
self.__lock.acquire()
|
||||
query = """--sql
|
||||
SELECT item_id,
|
||||
status,
|
||||
priority,
|
||||
field_values,
|
||||
error_type,
|
||||
error_message,
|
||||
error_traceback,
|
||||
created_at,
|
||||
updated_at,
|
||||
completed_at,
|
||||
started_at,
|
||||
session_id,
|
||||
batch_id,
|
||||
queue_id,
|
||||
origin,
|
||||
destination
|
||||
FROM session_queue
|
||||
WHERE queue_id = ?
|
||||
"""
|
||||
params: list[Union[str, int]] = [queue_id]
|
||||
|
||||
if status is not None:
|
||||
query += """--sql
|
||||
AND status = ?
|
||||
"""
|
||||
params.append(status)
|
||||
|
||||
if item_id is not None:
|
||||
query += """--sql
|
||||
AND (priority < ?) OR (priority = ? AND item_id > ?)
|
||||
"""
|
||||
params.extend([priority, priority, item_id])
|
||||
cursor_ = self._conn.cursor()
|
||||
item_id = cursor
|
||||
query = """--sql
|
||||
SELECT item_id,
|
||||
status,
|
||||
priority,
|
||||
field_values,
|
||||
error_type,
|
||||
error_message,
|
||||
error_traceback,
|
||||
created_at,
|
||||
updated_at,
|
||||
completed_at,
|
||||
started_at,
|
||||
session_id,
|
||||
batch_id,
|
||||
queue_id,
|
||||
origin,
|
||||
destination
|
||||
FROM session_queue
|
||||
WHERE queue_id = ?
|
||||
"""
|
||||
params: list[Union[str, int]] = [queue_id]
|
||||
|
||||
if status is not None:
|
||||
query += """--sql
|
||||
ORDER BY
|
||||
priority DESC,
|
||||
item_id ASC
|
||||
LIMIT ?
|
||||
AND status = ?
|
||||
"""
|
||||
params.append(limit + 1)
|
||||
self.__cursor.execute(query, params)
|
||||
results = cast(list[sqlite3.Row], self.__cursor.fetchall())
|
||||
items = [SessionQueueItemDTO.queue_item_dto_from_dict(dict(result)) for result in results]
|
||||
has_more = False
|
||||
if len(items) > limit:
|
||||
# remove the extra item
|
||||
items.pop()
|
||||
has_more = True
|
||||
except Exception:
|
||||
self.__conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self.__lock.release()
|
||||
params.append(status)
|
||||
|
||||
if item_id is not None:
|
||||
query += """--sql
|
||||
AND (priority < ?) OR (priority = ? AND item_id > ?)
|
||||
"""
|
||||
params.extend([priority, priority, item_id])
|
||||
|
||||
query += """--sql
|
||||
ORDER BY
|
||||
priority DESC,
|
||||
item_id ASC
|
||||
LIMIT ?
|
||||
"""
|
||||
params.append(limit + 1)
|
||||
cursor_.execute(query, params)
|
||||
results = cast(list[sqlite3.Row], cursor_.fetchall())
|
||||
items = [SessionQueueItemDTO.queue_item_dto_from_dict(dict(result)) for result in results]
|
||||
has_more = False
|
||||
if len(items) > limit:
|
||||
# remove the extra item
|
||||
items.pop()
|
||||
has_more = True
|
||||
return CursorPaginatedResults(items=items, limit=limit, has_more=has_more)
|
||||
|
||||
def get_queue_status(self, queue_id: str) -> SessionQueueStatus:
|
||||
try:
|
||||
self.__lock.acquire()
|
||||
self.__cursor.execute(
|
||||
"""--sql
|
||||
SELECT status, count(*)
|
||||
FROM session_queue
|
||||
WHERE queue_id = ?
|
||||
GROUP BY status
|
||||
""",
|
||||
(queue_id,),
|
||||
)
|
||||
counts_result = cast(list[sqlite3.Row], self.__cursor.fetchall())
|
||||
except Exception:
|
||||
self.__conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self.__lock.release()
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT status, count(*)
|
||||
FROM session_queue
|
||||
WHERE queue_id = ?
|
||||
GROUP BY status
|
||||
""",
|
||||
(queue_id,),
|
||||
)
|
||||
counts_result = cast(list[sqlite3.Row], cursor.fetchall())
|
||||
|
||||
current_item = self.get_current(queue_id=queue_id)
|
||||
total = sum(row[1] for row in counts_result)
|
||||
@@ -691,29 +626,23 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
)
|
||||
|
||||
def get_batch_status(self, queue_id: str, batch_id: str) -> BatchStatus:
|
||||
try:
|
||||
self.__lock.acquire()
|
||||
self.__cursor.execute(
|
||||
"""--sql
|
||||
SELECT status, count(*), origin, destination
|
||||
FROM session_queue
|
||||
WHERE
|
||||
queue_id = ?
|
||||
AND batch_id = ?
|
||||
GROUP BY status
|
||||
""",
|
||||
(queue_id, batch_id),
|
||||
)
|
||||
result = cast(list[sqlite3.Row], self.__cursor.fetchall())
|
||||
total = sum(row[1] for row in result)
|
||||
counts: dict[str, int] = {row[0]: row[1] for row in result}
|
||||
origin = result[0]["origin"] if result else None
|
||||
destination = result[0]["destination"] if result else None
|
||||
except Exception:
|
||||
self.__conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self.__lock.release()
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT status, count(*), origin, destination
|
||||
FROM session_queue
|
||||
WHERE
|
||||
queue_id = ?
|
||||
AND batch_id = ?
|
||||
GROUP BY status
|
||||
""",
|
||||
(queue_id, batch_id),
|
||||
)
|
||||
result = cast(list[sqlite3.Row], cursor.fetchall())
|
||||
total = sum(row[1] for row in result)
|
||||
counts: dict[str, int] = {row[0]: row[1] for row in result}
|
||||
origin = result[0]["origin"] if result else None
|
||||
destination = result[0]["destination"] if result else None
|
||||
|
||||
return BatchStatus(
|
||||
batch_id=batch_id,
|
||||
@@ -729,24 +658,18 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
)
|
||||
|
||||
def get_counts_by_destination(self, queue_id: str, destination: str) -> SessionQueueCountsByDestination:
|
||||
try:
|
||||
self.__lock.acquire()
|
||||
self.__cursor.execute(
|
||||
"""--sql
|
||||
SELECT status, count(*)
|
||||
FROM session_queue
|
||||
WHERE queue_id = ?
|
||||
AND destination = ?
|
||||
GROUP BY status
|
||||
""",
|
||||
(queue_id, destination),
|
||||
)
|
||||
counts_result = cast(list[sqlite3.Row], self.__cursor.fetchall())
|
||||
except Exception:
|
||||
self.__conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self.__lock.release()
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT status, count(*)
|
||||
FROM session_queue
|
||||
WHERE queue_id = ?
|
||||
AND destination = ?
|
||||
GROUP BY status
|
||||
""",
|
||||
(queue_id, destination),
|
||||
)
|
||||
counts_result = cast(list[sqlite3.Row], cursor.fetchall())
|
||||
|
||||
total = sum(row[1] for row in counts_result)
|
||||
counts: dict[str, int] = {row[0]: row[1] for row in counts_result}
|
||||
@@ -761,3 +684,68 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
canceled=counts.get("canceled", 0),
|
||||
total=total,
|
||||
)
|
||||
|
||||
def retry_items_by_id(self, queue_id: str, item_ids: list[int]) -> RetryItemsResult:
|
||||
"""Retries the given queue items"""
|
||||
try:
|
||||
cursor = self._conn.cursor()
|
||||
values_to_insert: list[tuple] = []
|
||||
retried_item_ids: list[int] = []
|
||||
|
||||
for item_id in item_ids:
|
||||
queue_item = self.get_queue_item(item_id)
|
||||
|
||||
if queue_item.status not in ("failed", "canceled"):
|
||||
continue
|
||||
|
||||
retried_item_ids.append(item_id)
|
||||
|
||||
field_values_json = (
|
||||
json.dumps(queue_item.field_values, default=to_jsonable_python) if queue_item.field_values else None
|
||||
)
|
||||
workflow_json = (
|
||||
json.dumps(queue_item.workflow, default=to_jsonable_python) if queue_item.workflow else None
|
||||
)
|
||||
cloned_session = GraphExecutionState(graph=queue_item.session.graph)
|
||||
cloned_session_json = cloned_session.model_dump_json(warnings=False, exclude_none=True)
|
||||
|
||||
retried_from_item_id = (
|
||||
queue_item.retried_from_item_id
|
||||
if queue_item.retried_from_item_id is not None
|
||||
else queue_item.item_id
|
||||
)
|
||||
|
||||
value_to_insert = (
|
||||
queue_item.queue_id,
|
||||
queue_item.batch_id,
|
||||
queue_item.destination,
|
||||
field_values_json,
|
||||
queue_item.origin,
|
||||
queue_item.priority,
|
||||
workflow_json,
|
||||
cloned_session_json,
|
||||
cloned_session.id,
|
||||
retried_from_item_id,
|
||||
)
|
||||
values_to_insert.append(value_to_insert)
|
||||
|
||||
# TODO(psyche): Handle max queue size?
|
||||
|
||||
cursor.executemany(
|
||||
"""--sql
|
||||
INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
values_to_insert,
|
||||
)
|
||||
|
||||
self._conn.commit()
|
||||
except Exception:
|
||||
self._conn.rollback()
|
||||
raise
|
||||
retry_result = RetryItemsResult(
|
||||
queue_id=queue_id,
|
||||
retried_item_ids=retried_item_ids,
|
||||
)
|
||||
self.__invoker.services.events.emit_queue_items_retried(retry_result)
|
||||
return retry_result
|
||||
|
||||
@@ -9,6 +9,7 @@ from torch import Tensor
|
||||
|
||||
from invokeai.app.invocations.constants import IMAGE_MODES
|
||||
from invokeai.app.invocations.fields import MetadataField, WithBoard, WithMetadata
|
||||
from invokeai.app.services.board_records.board_records_common import BoardRecordOrderBy
|
||||
from invokeai.app.services.boards.boards_common import BoardDTO
|
||||
from invokeai.app.services.config.config_default import InvokeAIAppConfig
|
||||
from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin
|
||||
@@ -16,6 +17,7 @@ from invokeai.app.services.images.images_common import ImageDTO
|
||||
from invokeai.app.services.invocation_services import InvocationServices
|
||||
from invokeai.app.services.model_records.model_records_base import UnknownModelException
|
||||
from invokeai.app.services.session_processor.session_processor_common import ProgressImage
|
||||
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
|
||||
from invokeai.app.util.step_callback import flux_step_callback, stable_diffusion_step_callback
|
||||
from invokeai.backend.model_manager.config import (
|
||||
AnyModel,
|
||||
@@ -102,7 +104,9 @@ class BoardsInterface(InvocationContextInterface):
|
||||
Returns:
|
||||
A list of all boards.
|
||||
"""
|
||||
return self._services.boards.get_all()
|
||||
return self._services.boards.get_all(
|
||||
order_by=BoardRecordOrderBy.CreatedAt, direction=SQLiteDirection.Descending
|
||||
)
|
||||
|
||||
def add_image_to_board(self, board_id: str, image_name: str) -> None:
|
||||
"""Adds an image to a board.
|
||||
@@ -122,7 +126,11 @@ class BoardsInterface(InvocationContextInterface):
|
||||
Returns:
|
||||
A list of all image names for the board.
|
||||
"""
|
||||
return self._services.board_images.get_all_board_image_names_for_board(board_id)
|
||||
return self._services.board_images.get_all_board_image_names_for_board(
|
||||
board_id,
|
||||
categories=None,
|
||||
is_intermediate=None,
|
||||
)
|
||||
|
||||
|
||||
class LoggerInterface(InvocationContextInterface):
|
||||
@@ -283,7 +291,7 @@ class ImagesInterface(InvocationContextInterface):
|
||||
Returns:
|
||||
The local path of the image or thumbnail.
|
||||
"""
|
||||
return self._services.images.get_path(image_name, thumbnail)
|
||||
return Path(self._services.images.get_path(image_name, thumbnail))
|
||||
|
||||
|
||||
class TensorsInterface(InvocationContextInterface):
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import sqlite3
|
||||
import threading
|
||||
from logging import Logger
|
||||
from pathlib import Path
|
||||
|
||||
@@ -38,14 +37,20 @@ class SqliteDatabase:
|
||||
self.logger.info(f"Initializing database at {self.db_path}")
|
||||
|
||||
self.conn = sqlite3.connect(database=self.db_path or sqlite_memory, check_same_thread=False)
|
||||
self.lock = threading.RLock()
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
|
||||
if self.verbose:
|
||||
self.conn.set_trace_callback(self.logger.debug)
|
||||
|
||||
# Enable foreign key constraints
|
||||
self.conn.execute("PRAGMA foreign_keys = ON;")
|
||||
|
||||
# Enable Write-Ahead Logging (WAL) mode for better concurrency
|
||||
self.conn.execute("PRAGMA journal_mode = WAL;")
|
||||
|
||||
# Set a busy timeout to prevent database lockups during writes
|
||||
self.conn.execute("PRAGMA busy_timeout = 5000;") # 5 seconds
|
||||
|
||||
def clean(self) -> None:
|
||||
"""
|
||||
Cleans the database by running the VACUUM command, reporting on the freed space.
|
||||
@@ -53,15 +58,14 @@ class SqliteDatabase:
|
||||
# No need to clean in-memory database
|
||||
if not self.db_path:
|
||||
return
|
||||
with self.lock:
|
||||
try:
|
||||
initial_db_size = Path(self.db_path).stat().st_size
|
||||
self.conn.execute("VACUUM;")
|
||||
self.conn.commit()
|
||||
final_db_size = Path(self.db_path).stat().st_size
|
||||
freed_space_in_mb = round((initial_db_size - final_db_size) / 1024 / 1024, 2)
|
||||
if freed_space_in_mb > 0:
|
||||
self.logger.info(f"Cleaned database (freed {freed_space_in_mb}MB)")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error cleaning database: {e}")
|
||||
raise
|
||||
try:
|
||||
initial_db_size = Path(self.db_path).stat().st_size
|
||||
self.conn.execute("VACUUM;")
|
||||
self.conn.commit()
|
||||
final_db_size = Path(self.db_path).stat().st_size
|
||||
freed_space_in_mb = round((initial_db_size - final_db_size) / 1024 / 1024, 2)
|
||||
if freed_space_in_mb > 0:
|
||||
self.logger.info(f"Cleaned database (freed {freed_space_in_mb}MB)")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error cleaning database: {e}")
|
||||
raise
|
||||
|
||||
@@ -18,6 +18,7 @@ from invokeai.app.services.shared.sqlite_migrator.migrations.migration_12 import
|
||||
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_13 import build_migration_13
|
||||
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_14 import build_migration_14
|
||||
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_15 import build_migration_15
|
||||
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_16 import build_migration_16
|
||||
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import SqliteMigrator
|
||||
|
||||
|
||||
@@ -53,6 +54,7 @@ def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileSto
|
||||
migrator.register_migration(build_migration_13())
|
||||
migrator.register_migration(build_migration_14())
|
||||
migrator.register_migration(build_migration_15())
|
||||
migrator.register_migration(build_migration_16())
|
||||
migrator.run_migrations()
|
||||
|
||||
return db
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import sqlite3
|
||||
|
||||
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
|
||||
|
||||
|
||||
class Migration16Callback:
|
||||
def __call__(self, cursor: sqlite3.Cursor) -> None:
|
||||
self._add_retried_from_item_id_col(cursor)
|
||||
|
||||
def _add_retried_from_item_id_col(self, cursor: sqlite3.Cursor) -> None:
|
||||
"""
|
||||
- Adds `retried_from_item_id` column to the session queue table.
|
||||
"""
|
||||
|
||||
cursor.execute("ALTER TABLE session_queue ADD COLUMN retried_from_item_id INTEGER;")
|
||||
|
||||
|
||||
def build_migration_16() -> Migration:
|
||||
"""
|
||||
Build the migration from database version 15 to 16.
|
||||
|
||||
This migration does the following:
|
||||
- Adds `retried_from_item_id` column to the session queue table.
|
||||
"""
|
||||
migration_16 = Migration(
|
||||
from_version=15,
|
||||
to_version=16,
|
||||
callback=Migration16Callback(),
|
||||
)
|
||||
|
||||
return migration_16
|
||||
@@ -43,46 +43,45 @@ class SqliteMigrator:
|
||||
|
||||
def run_migrations(self) -> bool:
|
||||
"""Migrates the database to the latest version."""
|
||||
with self._db.lock:
|
||||
# This throws if there is a problem.
|
||||
self._migration_set.validate_migration_chain()
|
||||
cursor = self._db.conn.cursor()
|
||||
self._create_migrations_table(cursor=cursor)
|
||||
# This throws if there is a problem.
|
||||
self._migration_set.validate_migration_chain()
|
||||
cursor = self._db.conn.cursor()
|
||||
self._create_migrations_table(cursor=cursor)
|
||||
|
||||
if self._migration_set.count == 0:
|
||||
self._logger.debug("No migrations registered")
|
||||
return False
|
||||
if self._migration_set.count == 0:
|
||||
self._logger.debug("No migrations registered")
|
||||
return False
|
||||
|
||||
if self._get_current_version(cursor=cursor) == self._migration_set.latest_version:
|
||||
self._logger.debug("Database is up to date, no migrations to run")
|
||||
return False
|
||||
if self._get_current_version(cursor=cursor) == self._migration_set.latest_version:
|
||||
self._logger.debug("Database is up to date, no migrations to run")
|
||||
return False
|
||||
|
||||
self._logger.info("Database update needed")
|
||||
self._logger.info("Database update needed")
|
||||
|
||||
# Make a backup of the db if it needs to be updated and is a file db
|
||||
if self._db.db_path is not None:
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
self._backup_path = self._db.db_path.parent / f"{self._db.db_path.stem}_backup_{timestamp}.db"
|
||||
self._logger.info(f"Backing up database to {str(self._backup_path)}")
|
||||
# Use SQLite to do the backup
|
||||
with closing(sqlite3.connect(self._backup_path)) as backup_conn:
|
||||
self._db.conn.backup(backup_conn)
|
||||
else:
|
||||
self._logger.info("Using in-memory database, no backup needed")
|
||||
# Make a backup of the db if it needs to be updated and is a file db
|
||||
if self._db.db_path is not None:
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
self._backup_path = self._db.db_path.parent / f"{self._db.db_path.stem}_backup_{timestamp}.db"
|
||||
self._logger.info(f"Backing up database to {str(self._backup_path)}")
|
||||
# Use SQLite to do the backup
|
||||
with closing(sqlite3.connect(self._backup_path)) as backup_conn:
|
||||
self._db.conn.backup(backup_conn)
|
||||
else:
|
||||
self._logger.info("Using in-memory database, no backup needed")
|
||||
|
||||
next_migration = self._migration_set.get(from_version=self._get_current_version(cursor))
|
||||
while next_migration is not None:
|
||||
self._run_migration(next_migration)
|
||||
next_migration = self._migration_set.get(self._get_current_version(cursor))
|
||||
self._logger.info("Database updated successfully")
|
||||
return True
|
||||
next_migration = self._migration_set.get(from_version=self._get_current_version(cursor))
|
||||
while next_migration is not None:
|
||||
self._run_migration(next_migration)
|
||||
next_migration = self._migration_set.get(self._get_current_version(cursor))
|
||||
self._logger.info("Database updated successfully")
|
||||
return True
|
||||
|
||||
def _run_migration(self, migration: Migration) -> None:
|
||||
"""Runs a single migration."""
|
||||
try:
|
||||
# Using sqlite3.Connection as a context manager commits a the transaction on exit, or rolls it back if an
|
||||
# exception is raised.
|
||||
with self._db.lock, self._db.conn as conn:
|
||||
with self._db.conn as conn:
|
||||
cursor = conn.cursor()
|
||||
if self._get_current_version(cursor) != migration.from_version:
|
||||
raise MigrationError(
|
||||
@@ -108,27 +107,26 @@ class SqliteMigrator:
|
||||
|
||||
def _create_migrations_table(self, cursor: sqlite3.Cursor) -> None:
|
||||
"""Creates the migrations table for the database, if one does not already exist."""
|
||||
with self._db.lock:
|
||||
try:
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='migrations';")
|
||||
if cursor.fetchone() is not None:
|
||||
return
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
CREATE TABLE migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
migrated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))
|
||||
);
|
||||
"""
|
||||
)
|
||||
cursor.execute("INSERT INTO migrations (version) VALUES (0);")
|
||||
cursor.connection.commit()
|
||||
self._logger.debug("Created migrations table")
|
||||
except sqlite3.Error as e:
|
||||
msg = f"Problem creating migrations table: {e}"
|
||||
self._logger.error(msg)
|
||||
cursor.connection.rollback()
|
||||
raise MigrationError(msg) from e
|
||||
try:
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='migrations';")
|
||||
if cursor.fetchone() is not None:
|
||||
return
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
CREATE TABLE migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
migrated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))
|
||||
);
|
||||
"""
|
||||
)
|
||||
cursor.execute("INSERT INTO migrations (version) VALUES (0);")
|
||||
cursor.connection.commit()
|
||||
self._logger.debug("Created migrations table")
|
||||
except sqlite3.Error as e:
|
||||
msg = f"Problem creating migrations table: {e}"
|
||||
self._logger.error(msg)
|
||||
cursor.connection.rollback()
|
||||
raise MigrationError(msg) from e
|
||||
|
||||
@classmethod
|
||||
def _get_current_version(cls, cursor: sqlite3.Cursor) -> int:
|
||||
|
||||
@@ -17,9 +17,7 @@ from invokeai.app.util.misc import uuid_string
|
||||
class SqliteStylePresetRecordsStorage(StylePresetRecordsStorageBase):
|
||||
def __init__(self, db: SqliteDatabase) -> None:
|
||||
super().__init__()
|
||||
self._lock = db.lock
|
||||
self._conn = db.conn
|
||||
self._cursor = self._conn.cursor()
|
||||
|
||||
def start(self, invoker: Invoker) -> None:
|
||||
self._invoker = invoker
|
||||
@@ -27,31 +25,25 @@ class SqliteStylePresetRecordsStorage(StylePresetRecordsStorageBase):
|
||||
|
||||
def get(self, style_preset_id: str) -> StylePresetRecordDTO:
|
||||
"""Gets a style preset by ID."""
|
||||
try:
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
"""--sql
|
||||
SELECT *
|
||||
FROM style_presets
|
||||
WHERE id = ?;
|
||||
""",
|
||||
(style_preset_id,),
|
||||
)
|
||||
row = self._cursor.fetchone()
|
||||
if row is None:
|
||||
raise StylePresetNotFoundError(f"Style preset with id {style_preset_id} not found")
|
||||
return StylePresetRecordDTO.from_dict(dict(row))
|
||||
except Exception:
|
||||
self._conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._lock.release()
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT *
|
||||
FROM style_presets
|
||||
WHERE id = ?;
|
||||
""",
|
||||
(style_preset_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row is None:
|
||||
raise StylePresetNotFoundError(f"Style preset with id {style_preset_id} not found")
|
||||
return StylePresetRecordDTO.from_dict(dict(row))
|
||||
|
||||
def create(self, style_preset: StylePresetWithoutId) -> StylePresetRecordDTO:
|
||||
style_preset_id = uuid_string()
|
||||
try:
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT OR IGNORE INTO style_presets (
|
||||
id,
|
||||
@@ -72,18 +64,16 @@ class SqliteStylePresetRecordsStorage(StylePresetRecordsStorageBase):
|
||||
except Exception:
|
||||
self._conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._lock.release()
|
||||
return self.get(style_preset_id)
|
||||
|
||||
def create_many(self, style_presets: list[StylePresetWithoutId]) -> None:
|
||||
style_preset_ids = []
|
||||
try:
|
||||
self._lock.acquire()
|
||||
cursor = self._conn.cursor()
|
||||
for style_preset in style_presets:
|
||||
style_preset_id = uuid_string()
|
||||
style_preset_ids.append(style_preset_id)
|
||||
self._cursor.execute(
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT OR IGNORE INTO style_presets (
|
||||
id,
|
||||
@@ -104,17 +94,15 @@ class SqliteStylePresetRecordsStorage(StylePresetRecordsStorageBase):
|
||||
except Exception:
|
||||
self._conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._lock.release()
|
||||
|
||||
return None
|
||||
|
||||
def update(self, style_preset_id: str, changes: StylePresetChanges) -> StylePresetRecordDTO:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
cursor = self._conn.cursor()
|
||||
# Change the name of a style preset
|
||||
if changes.name is not None:
|
||||
self._cursor.execute(
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
UPDATE style_presets
|
||||
SET name = ?
|
||||
@@ -125,7 +113,7 @@ class SqliteStylePresetRecordsStorage(StylePresetRecordsStorageBase):
|
||||
|
||||
# Change the preset data for a style preset
|
||||
if changes.preset_data is not None:
|
||||
self._cursor.execute(
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
UPDATE style_presets
|
||||
SET preset_data = ?
|
||||
@@ -138,14 +126,12 @@ class SqliteStylePresetRecordsStorage(StylePresetRecordsStorageBase):
|
||||
except Exception:
|
||||
self._conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._lock.release()
|
||||
return self.get(style_preset_id)
|
||||
|
||||
def delete(self, style_preset_id: str) -> None:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
DELETE from style_presets
|
||||
WHERE id = ?;
|
||||
@@ -156,46 +142,38 @@ class SqliteStylePresetRecordsStorage(StylePresetRecordsStorageBase):
|
||||
except Exception:
|
||||
self._conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._lock.release()
|
||||
return None
|
||||
|
||||
def get_many(self, type: PresetType | None = None) -> list[StylePresetRecordDTO]:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
main_query = """
|
||||
SELECT
|
||||
*
|
||||
FROM style_presets
|
||||
"""
|
||||
main_query = """
|
||||
SELECT
|
||||
*
|
||||
FROM style_presets
|
||||
"""
|
||||
|
||||
if type is not None:
|
||||
main_query += "WHERE type = ? "
|
||||
if type is not None:
|
||||
main_query += "WHERE type = ? "
|
||||
|
||||
main_query += "ORDER BY LOWER(name) ASC"
|
||||
main_query += "ORDER BY LOWER(name) ASC"
|
||||
|
||||
if type is not None:
|
||||
self._cursor.execute(main_query, (type,))
|
||||
else:
|
||||
self._cursor.execute(main_query)
|
||||
cursor = self._conn.cursor()
|
||||
if type is not None:
|
||||
cursor.execute(main_query, (type,))
|
||||
else:
|
||||
cursor.execute(main_query)
|
||||
|
||||
rows = self._cursor.fetchall()
|
||||
style_presets = [StylePresetRecordDTO.from_dict(dict(row)) for row in rows]
|
||||
rows = cursor.fetchall()
|
||||
style_presets = [StylePresetRecordDTO.from_dict(dict(row)) for row in rows]
|
||||
|
||||
return style_presets
|
||||
except Exception:
|
||||
self._conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._lock.release()
|
||||
return style_presets
|
||||
|
||||
def _sync_default_style_presets(self) -> None:
|
||||
"""Syncs default style presets to the database. Internal use only."""
|
||||
|
||||
# First delete all existing default style presets
|
||||
try:
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
DELETE FROM style_presets
|
||||
WHERE type = "default";
|
||||
@@ -205,10 +183,8 @@ class SqliteStylePresetRecordsStorage(StylePresetRecordsStorageBase):
|
||||
except Exception:
|
||||
self._conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._lock.release()
|
||||
# Next, parse and create the default style presets
|
||||
with self._lock, open(Path(__file__).parent / Path("default_style_presets.json"), "r") as file:
|
||||
with open(Path(__file__).parent / Path("default_style_presets.json"), "r") as file:
|
||||
presets = json.load(file)
|
||||
for preset in presets:
|
||||
style_preset = StylePresetWithoutId.model_validate(preset)
|
||||
|
||||
@@ -62,9 +62,13 @@ class WorkflowWithoutID(BaseModel):
|
||||
notes: str = Field(description="The notes of the workflow.")
|
||||
exposedFields: list[ExposedField] = Field(description="The exposed fields of the workflow.")
|
||||
meta: WorkflowMeta = Field(description="The meta of the workflow.")
|
||||
# TODO: nodes and edges are very loosely typed
|
||||
# TODO(psyche): nodes, edges and form are very loosely typed - they are strictly modeled and checked on the frontend.
|
||||
nodes: list[dict[str, JsonValue]] = Field(description="The nodes of the workflow.")
|
||||
edges: list[dict[str, JsonValue]] = Field(description="The edges of the workflow.")
|
||||
# TODO(psyche): We have a crapload of workflows that have no form, bc it was added after we introduced workflows.
|
||||
# This is typed as optional to prevent errors when pulling workflows from the DB. The frontend adds a default form if
|
||||
# it is None.
|
||||
form: dict[str, JsonValue] | None = Field(default=None, description="The form of the workflow.")
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
@@ -23,9 +23,7 @@ from invokeai.app.util.misc import uuid_string
|
||||
class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
|
||||
def __init__(self, db: SqliteDatabase) -> None:
|
||||
super().__init__()
|
||||
self._lock = db.lock
|
||||
self._conn = db.conn
|
||||
self._cursor = self._conn.cursor()
|
||||
|
||||
def start(self, invoker: Invoker) -> None:
|
||||
self._invoker = invoker
|
||||
@@ -33,42 +31,36 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
|
||||
|
||||
def get(self, workflow_id: str) -> WorkflowRecordDTO:
|
||||
"""Gets a workflow by ID. Updates the opened_at column."""
|
||||
try:
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
"""--sql
|
||||
UPDATE workflow_library
|
||||
SET opened_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
|
||||
WHERE workflow_id = ?;
|
||||
""",
|
||||
(workflow_id,),
|
||||
)
|
||||
self._conn.commit()
|
||||
self._cursor.execute(
|
||||
"""--sql
|
||||
SELECT workflow_id, workflow, name, created_at, updated_at, opened_at
|
||||
FROM workflow_library
|
||||
WHERE workflow_id = ?;
|
||||
""",
|
||||
(workflow_id,),
|
||||
)
|
||||
row = self._cursor.fetchone()
|
||||
if row is None:
|
||||
raise WorkflowNotFoundError(f"Workflow with id {workflow_id} not found")
|
||||
return WorkflowRecordDTO.from_dict(dict(row))
|
||||
except Exception:
|
||||
self._conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._lock.release()
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
UPDATE workflow_library
|
||||
SET opened_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
|
||||
WHERE workflow_id = ?;
|
||||
""",
|
||||
(workflow_id,),
|
||||
)
|
||||
self._conn.commit()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
SELECT workflow_id, workflow, name, created_at, updated_at, opened_at
|
||||
FROM workflow_library
|
||||
WHERE workflow_id = ?;
|
||||
""",
|
||||
(workflow_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row is None:
|
||||
raise WorkflowNotFoundError(f"Workflow with id {workflow_id} not found")
|
||||
return WorkflowRecordDTO.from_dict(dict(row))
|
||||
|
||||
def create(self, workflow: WorkflowWithoutID) -> WorkflowRecordDTO:
|
||||
try:
|
||||
# Only user workflows may be created by this method
|
||||
assert workflow.meta.category is WorkflowCategory.User
|
||||
workflow_with_id = Workflow(**workflow.model_dump(), id=uuid_string())
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT OR IGNORE INTO workflow_library (
|
||||
workflow_id,
|
||||
@@ -82,14 +74,12 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
|
||||
except Exception:
|
||||
self._conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._lock.release()
|
||||
return self.get(workflow_with_id.id)
|
||||
|
||||
def update(self, workflow: Workflow) -> WorkflowRecordDTO:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
UPDATE workflow_library
|
||||
SET workflow = ?
|
||||
@@ -101,14 +91,12 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
|
||||
except Exception:
|
||||
self._conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._lock.release()
|
||||
return self.get(workflow.id)
|
||||
|
||||
def delete(self, workflow_id: str) -> None:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
self._cursor.execute(
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
DELETE from workflow_library
|
||||
WHERE workflow_id = ? AND category = 'user';
|
||||
@@ -119,8 +107,6 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
|
||||
except Exception:
|
||||
self._conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._lock.release()
|
||||
return None
|
||||
|
||||
def get_many(
|
||||
@@ -132,66 +118,60 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
|
||||
per_page: Optional[int] = None,
|
||||
query: Optional[str] = None,
|
||||
) -> PaginatedResults[WorkflowRecordListItemDTO]:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
# sanitize!
|
||||
assert order_by in WorkflowRecordOrderBy
|
||||
assert direction in SQLiteDirection
|
||||
assert category in WorkflowCategory
|
||||
count_query = "SELECT COUNT(*) FROM workflow_library WHERE category = ?"
|
||||
main_query = """
|
||||
SELECT
|
||||
workflow_id,
|
||||
category,
|
||||
name,
|
||||
description,
|
||||
created_at,
|
||||
updated_at,
|
||||
opened_at
|
||||
FROM workflow_library
|
||||
WHERE category = ?
|
||||
"""
|
||||
main_params: list[int | str] = [category.value]
|
||||
count_params: list[int | str] = [category.value]
|
||||
# sanitize!
|
||||
assert order_by in WorkflowRecordOrderBy
|
||||
assert direction in SQLiteDirection
|
||||
assert category in WorkflowCategory
|
||||
count_query = "SELECT COUNT(*) FROM workflow_library WHERE category = ?"
|
||||
main_query = """
|
||||
SELECT
|
||||
workflow_id,
|
||||
category,
|
||||
name,
|
||||
description,
|
||||
created_at,
|
||||
updated_at,
|
||||
opened_at
|
||||
FROM workflow_library
|
||||
WHERE category = ?
|
||||
"""
|
||||
main_params: list[int | str] = [category.value]
|
||||
count_params: list[int | str] = [category.value]
|
||||
|
||||
stripped_query = query.strip() if query else None
|
||||
if stripped_query:
|
||||
wildcard_query = "%" + stripped_query + "%"
|
||||
main_query += " AND name LIKE ? OR description LIKE ? "
|
||||
count_query += " AND name LIKE ? OR description LIKE ?;"
|
||||
main_params.extend([wildcard_query, wildcard_query])
|
||||
count_params.extend([wildcard_query, wildcard_query])
|
||||
stripped_query = query.strip() if query else None
|
||||
if stripped_query:
|
||||
wildcard_query = "%" + stripped_query + "%"
|
||||
main_query += " AND name LIKE ? OR description LIKE ? "
|
||||
count_query += " AND name LIKE ? OR description LIKE ?;"
|
||||
main_params.extend([wildcard_query, wildcard_query])
|
||||
count_params.extend([wildcard_query, wildcard_query])
|
||||
|
||||
main_query += f" ORDER BY {order_by.value} {direction.value}"
|
||||
main_query += f" ORDER BY {order_by.value} {direction.value}"
|
||||
|
||||
if per_page:
|
||||
main_query += " LIMIT ? OFFSET ?"
|
||||
main_params.extend([per_page, page * per_page])
|
||||
if per_page:
|
||||
main_query += " LIMIT ? OFFSET ?"
|
||||
main_params.extend([per_page, page * per_page])
|
||||
|
||||
self._cursor.execute(main_query, main_params)
|
||||
rows = self._cursor.fetchall()
|
||||
workflows = [WorkflowRecordListItemDTOValidator.validate_python(dict(row)) for row in rows]
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(main_query, main_params)
|
||||
rows = cursor.fetchall()
|
||||
workflows = [WorkflowRecordListItemDTOValidator.validate_python(dict(row)) for row in rows]
|
||||
|
||||
self._cursor.execute(count_query, count_params)
|
||||
total = self._cursor.fetchone()[0]
|
||||
cursor.execute(count_query, count_params)
|
||||
total = cursor.fetchone()[0]
|
||||
|
||||
if per_page:
|
||||
pages = total // per_page + (total % per_page > 0)
|
||||
else:
|
||||
pages = 1 # If no pagination, there is only one page
|
||||
if per_page:
|
||||
pages = total // per_page + (total % per_page > 0)
|
||||
else:
|
||||
pages = 1 # If no pagination, there is only one page
|
||||
|
||||
return PaginatedResults(
|
||||
items=workflows,
|
||||
page=page,
|
||||
per_page=per_page if per_page else total,
|
||||
pages=pages,
|
||||
total=total,
|
||||
)
|
||||
except Exception:
|
||||
self._conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._lock.release()
|
||||
return PaginatedResults(
|
||||
items=workflows,
|
||||
page=page,
|
||||
per_page=per_page if per_page else total,
|
||||
pages=pages,
|
||||
total=total,
|
||||
)
|
||||
|
||||
def _sync_default_workflows(self) -> None:
|
||||
"""Syncs default workflows to the database. Internal use only."""
|
||||
@@ -207,7 +187,6 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
|
||||
"""
|
||||
|
||||
try:
|
||||
self._lock.acquire()
|
||||
workflows: list[Workflow] = []
|
||||
workflows_dir = Path(__file__).parent / Path("default_workflows")
|
||||
workflow_paths = workflows_dir.glob("*.json")
|
||||
@@ -218,14 +197,15 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
|
||||
workflows.append(workflow)
|
||||
# Only default workflows may be managed by this method
|
||||
assert all(w.meta.category is WorkflowCategory.Default for w in workflows)
|
||||
self._cursor.execute(
|
||||
cursor = self._conn.cursor()
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
DELETE FROM workflow_library
|
||||
WHERE category = 'default';
|
||||
"""
|
||||
)
|
||||
for w in workflows:
|
||||
self._cursor.execute(
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT OR REPLACE INTO workflow_library (
|
||||
workflow_id,
|
||||
@@ -239,5 +219,3 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
|
||||
except Exception:
|
||||
self._conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
self._lock.release()
|
||||
|
||||
64
invokeai/app/util/startup_utils.py
Normal file
64
invokeai/app/util/startup_utils.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import logging
|
||||
import mimetypes
|
||||
import socket
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
def find_open_port(port: int) -> int:
|
||||
"""Find a port not in use starting at given port"""
|
||||
# Taken from https://waylonwalker.com/python-find-available-port/, thanks Waylon!
|
||||
# https://github.com/WaylonWalker
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(1)
|
||||
if s.connect_ex(("localhost", port)) == 0:
|
||||
return find_open_port(port=port + 1)
|
||||
else:
|
||||
return port
|
||||
|
||||
|
||||
def check_cudnn(logger: logging.Logger) -> None:
|
||||
"""Check for cuDNN issues that could be causing degraded performance."""
|
||||
if torch.backends.cudnn.is_available():
|
||||
try:
|
||||
# Note: At the time of writing (torch 2.2.1), torch.backends.cudnn.version() only raises an error the first
|
||||
# time it is called. Subsequent calls will return the version number without complaining about a mismatch.
|
||||
cudnn_version = torch.backends.cudnn.version()
|
||||
logger.info(f"cuDNN version: {cudnn_version}")
|
||||
except RuntimeError as e:
|
||||
logger.warning(
|
||||
"Encountered a cuDNN version issue. This may result in degraded performance. This issue is usually "
|
||||
"caused by an incompatible cuDNN version installed in your python environment, or on the host "
|
||||
f"system. Full error message:\n{e}"
|
||||
)
|
||||
|
||||
|
||||
def enable_dev_reload() -> None:
|
||||
"""Enable hot reloading on python file changes during development."""
|
||||
from invokeai.backend.util.logging import InvokeAILogger
|
||||
|
||||
try:
|
||||
import jurigged
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
'Can\'t start `--dev_reload` because jurigged is not found; `pip install -e ".[dev]"` to include development dependencies.'
|
||||
) from e
|
||||
else:
|
||||
jurigged.watch(logger=InvokeAILogger.get_logger(name="jurigged").info)
|
||||
|
||||
|
||||
def apply_monkeypatches() -> None:
|
||||
"""Apply monkeypatches to fix issues with third-party libraries."""
|
||||
|
||||
import invokeai.backend.util.hotfixes # noqa: F401 (monkeypatching on import)
|
||||
|
||||
if torch.backends.mps.is_available():
|
||||
import invokeai.backend.util.mps_fixes # noqa: F401 (monkeypatching on import)
|
||||
|
||||
|
||||
def register_mime_types() -> None:
|
||||
"""Register additional mime types for windows."""
|
||||
# Fix for windows mimetypes registry entries being borked.
|
||||
# see https://github.com/invoke-ai/InvokeAI/discussions/3684#discussioncomment-6391352
|
||||
mimetypes.add_type("application/javascript", ".js")
|
||||
mimetypes.add_type("text/css", ".css")
|
||||
52
invokeai/app/util/torch_cuda_allocator.py
Normal file
52
invokeai/app/util/torch_cuda_allocator.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def configure_torch_cuda_allocator(pytorch_cuda_alloc_conf: str, logger: logging.Logger):
|
||||
"""Configure the PyTorch CUDA memory allocator. See
|
||||
https://pytorch.org/docs/stable/notes/cuda.html#optimizing-memory-usage-with-pytorch-cuda-alloc-conf for supported
|
||||
configurations.
|
||||
"""
|
||||
|
||||
if "torch" in sys.modules:
|
||||
raise RuntimeError("configure_torch_cuda_allocator() must be called before importing torch.")
|
||||
|
||||
# Log a warning if the PYTORCH_CUDA_ALLOC_CONF environment variable is already set.
|
||||
prev_cuda_alloc_conf = os.environ.get("PYTORCH_CUDA_ALLOC_CONF", None)
|
||||
if prev_cuda_alloc_conf is not None:
|
||||
if prev_cuda_alloc_conf == pytorch_cuda_alloc_conf:
|
||||
logger.info(
|
||||
f"PYTORCH_CUDA_ALLOC_CONF is already set to '{pytorch_cuda_alloc_conf}'. Skipping configuration."
|
||||
)
|
||||
return
|
||||
else:
|
||||
logger.warning(
|
||||
f"Attempted to configure the PyTorch CUDA memory allocator with '{pytorch_cuda_alloc_conf}', but PYTORCH_CUDA_ALLOC_CONF is already set to "
|
||||
f"'{prev_cuda_alloc_conf}'. Skipping configuration."
|
||||
)
|
||||
return
|
||||
|
||||
# Configure the PyTorch CUDA memory allocator.
|
||||
# NOTE: It is important that this happens before torch is imported.
|
||||
os.environ["PYTORCH_CUDA_ALLOC_CONF"] = pytorch_cuda_alloc_conf
|
||||
|
||||
import torch
|
||||
|
||||
# Relevant docs: https://pytorch.org/docs/stable/notes/cuda.html#optimizing-memory-usage-with-pytorch-cuda-alloc-conf
|
||||
if not torch.cuda.is_available():
|
||||
raise RuntimeError(
|
||||
"Attempted to configure the PyTorch CUDA memory allocator, but no CUDA devices are available."
|
||||
)
|
||||
|
||||
# Verify that the torch allocator was properly configured.
|
||||
allocator_backend = torch.cuda.get_allocator_backend()
|
||||
expected_backend = "cudaMallocAsync" if "cudaMallocAsync" in pytorch_cuda_alloc_conf else "native"
|
||||
if allocator_backend != expected_backend:
|
||||
raise RuntimeError(
|
||||
f"Failed to configure the PyTorch CUDA memory allocator. Expected backend: '{expected_backend}', but got "
|
||||
f"'{allocator_backend}'. Verify that 1) the pytorch_cuda_alloc_conf is set correctly, and 2) that torch is "
|
||||
"not imported before calling configure_torch_cuda_allocator()."
|
||||
)
|
||||
|
||||
logger.info(f"PyTorch CUDA memory allocator: {torch.cuda.get_allocator_backend()}")
|
||||
@@ -11,9 +11,11 @@
|
||||
<link id="invoke-favicon" rel="icon" type="icon" href="assets/images/invoke-favicon.svg" />
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
body,
|
||||
#root {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -23,4 +25,4 @@
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
@@ -58,10 +58,11 @@
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@dagrejs/graphlib": "^2.2.4",
|
||||
"@fontsource-variable/inter": "^5.1.0",
|
||||
"@invoke-ai/ui-library": "^0.0.44",
|
||||
"@invoke-ai/ui-library": "^0.0.46",
|
||||
"@nanostores/react": "^0.7.3",
|
||||
"@reduxjs/toolkit": "2.2.3",
|
||||
"@reduxjs/toolkit": "2.5.1",
|
||||
"@roarr/browser-log-writer": "^1.3.0",
|
||||
"@xyflow/react": "^12.4.2",
|
||||
"async-mutex": "^0.5.0",
|
||||
"chakra-react-select": "^4.9.2",
|
||||
"cmdk": "^1.0.0",
|
||||
@@ -74,6 +75,8 @@
|
||||
"idb-keyval": "^6.2.1",
|
||||
"jsondiffpatch": "^0.6.0",
|
||||
"konva": "^9.3.15",
|
||||
"linkify-react": "^4.2.0",
|
||||
"linkifyjs": "^4.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lru-cache": "^11.0.1",
|
||||
"mtwist": "^1.0.2",
|
||||
@@ -96,9 +99,9 @@
|
||||
"react-icons": "^5.3.0",
|
||||
"react-redux": "9.1.2",
|
||||
"react-resizable-panels": "^2.1.4",
|
||||
"react-textarea-autosize": "^8.5.7",
|
||||
"react-use": "^17.5.1",
|
||||
"react-virtuoso": "^4.10.4",
|
||||
"reactflow": "^11.11.4",
|
||||
"redux-dynamic-middlewares": "^2.2.0",
|
||||
"redux-remember": "^5.1.0",
|
||||
"redux-undo": "^1.1.0",
|
||||
@@ -126,7 +129,7 @@
|
||||
"@storybook/addon-storysource": "^8.3.4",
|
||||
"@storybook/manager-api": "^8.3.4",
|
||||
"@storybook/react": "^8.3.4",
|
||||
"@storybook/react-vite": "^8.3.4",
|
||||
"@storybook/react-vite": "^8.5.5",
|
||||
"@storybook/theming": "^8.3.4",
|
||||
"@types/dateformat": "^5.0.2",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
@@ -134,9 +137,9 @@
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react-swc": "^3.7.1",
|
||||
"@vitest/coverage-v8": "^1.6.0",
|
||||
"@vitest/ui": "^1.6.0",
|
||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||
"@vitest/coverage-v8": "^3.0.6",
|
||||
"@vitest/ui": "^3.0.6",
|
||||
"concurrently": "^8.2.2",
|
||||
"csstype": "^3.1.3",
|
||||
"dpdm": "^3.14.0",
|
||||
@@ -152,12 +155,12 @@
|
||||
"tsafe": "^1.7.5",
|
||||
"type-fest": "^4.26.1",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.8",
|
||||
"vite": "^6.1.0",
|
||||
"vite-plugin-css-injected-by-js": "^3.5.2",
|
||||
"vite-plugin-dts": "^3.9.1",
|
||||
"vite-plugin-dts": "^4.5.0",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
"vitest": "^1.6.0"
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": "8"
|
||||
|
||||
2320
invokeai/frontend/web/pnpm-lock.yaml
generated
2320
invokeai/frontend/web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -107,7 +107,13 @@
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"resetToDefaults": "Auf Standard zurücksetzen",
|
||||
"seed": "Seed"
|
||||
"seed": "Seed",
|
||||
"row": "Reihe",
|
||||
"column": "Spalte",
|
||||
"end": "Ende",
|
||||
"layout": "Layout",
|
||||
"board": "Ordner",
|
||||
"combinatorial": "Kombinatorisch"
|
||||
},
|
||||
"gallery": {
|
||||
"galleryImageSize": "Bildgröße",
|
||||
@@ -616,7 +622,9 @@
|
||||
"hfTokenUnableToVerify": "HF-Token kann nicht überprüft werden",
|
||||
"hfTokenUnableToVerifyErrorMessage": "HuggingFace-Token kann nicht überprüft werden. Dies ist wahrscheinlich auf einen Netzwerkfehler zurückzuführen. Bitte versuchen Sie es später erneut.",
|
||||
"hfTokenSaved": "HF-Token gespeichert",
|
||||
"hfTokenRequired": "Sie versuchen, ein Modell herunterzuladen, für das ein gültiges HuggingFace-Token erforderlich ist."
|
||||
"hfTokenRequired": "Sie versuchen, ein Modell herunterzuladen, für das ein gültiges HuggingFace-Token erforderlich ist.",
|
||||
"urlUnauthorizedErrorMessage2": "Hier erfahren wie.",
|
||||
"urlForbidden": "Sie haben keinen Zugriff auf dieses Modell"
|
||||
},
|
||||
"parameters": {
|
||||
"images": "Bilder",
|
||||
@@ -683,7 +691,8 @@
|
||||
"iterations": "Iterationen",
|
||||
"guidance": "Führung",
|
||||
"coherenceMode": "Modus",
|
||||
"recallMetadata": "Metadaten abrufen"
|
||||
"recallMetadata": "Metadaten abrufen",
|
||||
"gaussianBlur": "Gaußsche Unschärfe"
|
||||
},
|
||||
"settings": {
|
||||
"displayInProgress": "Zwischenbilder anzeigen",
|
||||
@@ -883,7 +892,8 @@
|
||||
"canvas": "Leinwand",
|
||||
"prompts_one": "Prompt",
|
||||
"prompts_other": "Prompts",
|
||||
"batchSize": "Stapelgröße"
|
||||
"batchSize": "Stapelgröße",
|
||||
"confirm": "Bestätigen"
|
||||
},
|
||||
"metadata": {
|
||||
"negativePrompt": "Negativ Beschreibung",
|
||||
@@ -1298,7 +1308,13 @@
|
||||
"noBatchGroup": "keine Gruppe",
|
||||
"generatorNoValues": "leer",
|
||||
"generatorLoading": "wird geladen",
|
||||
"generatorLoadFromFile": "Aus Datei laden"
|
||||
"generatorLoadFromFile": "Aus Datei laden",
|
||||
"showEdgeLabels": "Kantenbeschriftungen anzeigen",
|
||||
"downloadWorkflowError": "Fehler beim Herunterladen des Arbeitsablaufs",
|
||||
"nodeName": "Knotenname",
|
||||
"description": "Beschreibung",
|
||||
"loadWorkflowDesc": "Arbeitsablauf laden?",
|
||||
"loadWorkflowDesc2": "Ihr aktueller Arbeitsablauf enthält nicht gespeicherte Änderungen."
|
||||
},
|
||||
"hrf": {
|
||||
"enableHrf": "Korrektur für hohe Auflösungen",
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
"back": "Back",
|
||||
"batch": "Batch Manager",
|
||||
"beta": "Beta",
|
||||
"board": "Board",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"copy": "Copy",
|
||||
@@ -187,7 +188,10 @@
|
||||
"values": "Values",
|
||||
"resetToDefaults": "Reset to Defaults",
|
||||
"seed": "Seed",
|
||||
"combinatorial": "Combinatorial"
|
||||
"combinatorial": "Combinatorial",
|
||||
"layout": "Layout",
|
||||
"row": "Row",
|
||||
"column": "Column"
|
||||
},
|
||||
"hrf": {
|
||||
"hrf": "High Resolution Fix",
|
||||
@@ -225,6 +229,8 @@
|
||||
"cancelTooltip": "Cancel Current Item",
|
||||
"cancelSucceeded": "Item Canceled",
|
||||
"cancelFailed": "Problem Canceling Item",
|
||||
"retrySucceeded": "Item Retried",
|
||||
"retryFailed": "Problem Retrying Item",
|
||||
"confirm": "Confirm",
|
||||
"prune": "Prune",
|
||||
"pruneTooltip": "Prune {{item_count}} Completed Items",
|
||||
@@ -236,6 +242,7 @@
|
||||
"clearFailed": "Problem Clearing Queue",
|
||||
"cancelBatch": "Cancel Batch",
|
||||
"cancelItem": "Cancel Item",
|
||||
"retryItem": "Retry Item",
|
||||
"cancelBatchSucceeded": "Batch Canceled",
|
||||
"cancelBatchFailed": "Problem Canceling Batch",
|
||||
"clearQueueAlertDialog": "Clearing the queue immediately cancels any processing items and clears the queue entirely. Pending filters will be canceled.",
|
||||
@@ -305,6 +312,7 @@
|
||||
},
|
||||
"gallery": {
|
||||
"gallery": "Gallery",
|
||||
"images": "Images",
|
||||
"assets": "Assets",
|
||||
"alwaysShowImageSizeBadge": "Always Show Image Size Badge",
|
||||
"assetsTab": "Files you’ve uploaded for use in your projects.",
|
||||
@@ -875,11 +883,15 @@
|
||||
"parseString": "Parse String",
|
||||
"splitOn": "Split On",
|
||||
"noBatchGroup": "no group",
|
||||
"generatorImagesCategory": "Category",
|
||||
"generatorImages_one": "{{count}} image",
|
||||
"generatorImages_other": "{{count}} images",
|
||||
"generatorNRandomValues_one": "{{count}} random value",
|
||||
"generatorNRandomValues_other": "{{count}} random values",
|
||||
"generatorNoValues": "empty",
|
||||
"generatorLoading": "loading",
|
||||
"generatorLoadFromFile": "Load from File",
|
||||
"generatorImagesFromBoard": "Images from Board",
|
||||
"dynamicPromptsRandom": "Dynamic Prompts (Random)",
|
||||
"dynamicPromptsCombinatorial": "Dynamic Prompts (Combinatorial)",
|
||||
"addNode": "Add Node",
|
||||
@@ -896,6 +908,8 @@
|
||||
"missingNode": "Missing invocation node",
|
||||
"missingInvocationTemplate": "Missing invocation template",
|
||||
"missingFieldTemplate": "Missing field template",
|
||||
"missingSourceOrTargetNode": "Missing source or target node",
|
||||
"missingSourceOrTargetHandle": "Missing source or target handle",
|
||||
"nodePack": "Node pack",
|
||||
"collection": "Collection",
|
||||
"singleFieldType": "{{name}} (Single)",
|
||||
@@ -907,6 +921,7 @@
|
||||
"currentImage": "Current Image",
|
||||
"currentImageDescription": "Displays the current image in the Node Editor",
|
||||
"downloadWorkflow": "Download Workflow JSON",
|
||||
"downloadWorkflowError": "Error downloading workflow",
|
||||
"edge": "Edge",
|
||||
"edit": "Edit",
|
||||
"editMode": "Edit in Workflow Editor",
|
||||
@@ -931,6 +946,7 @@
|
||||
"noWorkflows": "No Workflows",
|
||||
"noMatchingWorkflows": "No Matching Workflows",
|
||||
"noWorkflow": "No Workflow",
|
||||
"unableToUpdateNode": "Node update failed: node {{node}} of type {{type}} (may require deleting and recreating)",
|
||||
"mismatchedVersion": "Invalid node: node {{node}} of type {{type}} has mismatched version (try updating?)",
|
||||
"missingTemplate": "Invalid node: node {{node}} of type {{type}} missing template (not installed?)",
|
||||
"sourceNodeDoesNotExist": "Invalid edge: source/output node {{node}} does not exist",
|
||||
@@ -938,12 +954,14 @@
|
||||
"sourceNodeFieldDoesNotExist": "Invalid edge: source/output field {{node}}.{{field}} does not exist",
|
||||
"targetNodeFieldDoesNotExist": "Invalid edge: target/input field {{node}}.{{field}} does not exist",
|
||||
"deletedInvalidEdge": "Deleted invalid edge {{source}} -> {{target}}",
|
||||
"deletedMissingNodeFieldFormElement": "Deleted missing form field: node {{nodeId}} field {{fieldName}}",
|
||||
"noConnectionInProgress": "No connection in progress",
|
||||
"node": "Node",
|
||||
"nodeOutputs": "Node Outputs",
|
||||
"nodeSearch": "Search for nodes",
|
||||
"nodeTemplate": "Node Template",
|
||||
"nodeType": "Node Type",
|
||||
"nodeName": "Node Name",
|
||||
"noFieldsLinearview": "No fields added to Linear View",
|
||||
"noFieldsViewMode": "This workflow has no selected fields to display. View the full workflow to configure values.",
|
||||
"workflowHelpText": "Need Help? Check out our guide to <LinkComponent>Getting Started with Workflows</LinkComponent>.",
|
||||
@@ -952,6 +970,7 @@
|
||||
"nodeVersion": "Node Version",
|
||||
"noOutputRecorded": "No outputs recorded",
|
||||
"notes": "Notes",
|
||||
"description": "Description",
|
||||
"notesDescription": "Add notes about your workflow",
|
||||
"problemSettingTitle": "Problem Setting Title",
|
||||
"resetToDefaultValue": "Reset to default value",
|
||||
@@ -961,6 +980,8 @@
|
||||
"newWorkflow": "New Workflow",
|
||||
"newWorkflowDesc": "Create a new workflow?",
|
||||
"newWorkflowDesc2": "Your current workflow has unsaved changes.",
|
||||
"loadWorkflowDesc": "Load workflow?",
|
||||
"loadWorkflowDesc2": "Your current workflow has unsaved changes.",
|
||||
"clearWorkflow": "Clear Workflow",
|
||||
"clearWorkflowDesc": "Clear this workflow and start a new one?",
|
||||
"clearWorkflowDesc2": "Your current workflow has unsaved changes.",
|
||||
@@ -990,6 +1011,7 @@
|
||||
"unknownOutput": "Unknown output: {{name}}",
|
||||
"updateNode": "Update Node",
|
||||
"updateApp": "Update App",
|
||||
"loadingTemplates": "Loading {{name}}",
|
||||
"updateAllNodes": "Update Nodes",
|
||||
"allNodesUpdated": "All Nodes Updated",
|
||||
"unableToUpdateNodes_one": "Unable to update {{count}} node",
|
||||
@@ -1065,7 +1087,7 @@
|
||||
"emptyBatches": "empty batches",
|
||||
"batchNodeNotConnected": "Batch node not connected: {{label}}",
|
||||
"batchNodeEmptyCollection": "Some batch nodes have empty collections",
|
||||
"invalidBatchConfigurationCannotCalculate": "Invalid batch configuration; cannot calculate",
|
||||
"collectionEmpty": "empty collection",
|
||||
"collectionTooFewItems": "too few items, minimum {{minItems}}",
|
||||
"collectionTooManyItems": "too many items, maximum {{maxItems}}",
|
||||
"collectionStringTooLong": "too long, max {{maxLength}}",
|
||||
@@ -1075,6 +1097,7 @@
|
||||
"collectionNumberGTExclusiveMax": "{{value}} >= {{exclusiveMaximum}} (exc max)",
|
||||
"collectionNumberLTExclusiveMin": "{{value}} <= {{exclusiveMinimum}} (exc min)",
|
||||
"collectionNumberNotMultipleOf": "{{value}} not multiple of {{multipleOf}}",
|
||||
"batchNodeCollectionSizeMismatchNoGroupId": "Batch group collection size mismatch",
|
||||
"batchNodeCollectionSizeMismatch": "Collection size mismatch on Batch {{batchGroupId}}",
|
||||
"noModelSelected": "No model selected",
|
||||
"noT5EncoderModelSelected": "No T5 Encoder model selected for FLUX generation",
|
||||
@@ -1694,7 +1717,38 @@
|
||||
"download": "Download",
|
||||
"copyShareLink": "Copy Share Link",
|
||||
"copyShareLinkForWorkflow": "Copy Share Link for Workflow",
|
||||
"delete": "Delete"
|
||||
"delete": "Delete",
|
||||
"openLibrary": "Open Library",
|
||||
"builder": {
|
||||
"deleteAllElements": "Delete All Form Elements",
|
||||
"resetAllNodeFields": "Reset All Node Fields",
|
||||
"builder": "Form Builder",
|
||||
"layout": "Layout",
|
||||
"row": "Row",
|
||||
"column": "Column",
|
||||
"container": "Container",
|
||||
"heading": "Heading",
|
||||
"text": "Text",
|
||||
"divider": "Divider",
|
||||
"nodeField": "Node Field",
|
||||
"zoomToNode": "Zoom to Node",
|
||||
"nodeFieldTooltip": "To add a node field, click the small plus sign button on the field in the Workflow Editor, or drag the field by its name into the form.",
|
||||
"addToForm": "Add to Form",
|
||||
"label": "Label",
|
||||
"showDescription": "Show Description",
|
||||
"component": "Component",
|
||||
"numberInput": "Number Input",
|
||||
"singleLine": "Single Line",
|
||||
"multiLine": "Multi Line",
|
||||
"slider": "Slider",
|
||||
"both": "Both",
|
||||
"emptyRootPlaceholderViewMode": "Click Edit to start building a form for this workflow.",
|
||||
"emptyRootPlaceholderEditMode": "Drag a form element or node field here to get started.",
|
||||
"containerPlaceholder": "Empty Container",
|
||||
"headingPlaceholder": "Empty Heading",
|
||||
"textPlaceholder": "Empty Text",
|
||||
"workflowBuilderAlphaWarning": "The workflow builder is currently in alpha. There may be breaking changes before the stable release."
|
||||
}
|
||||
},
|
||||
"controlLayers": {
|
||||
"regional": "Regional",
|
||||
@@ -1857,7 +1911,7 @@
|
||||
"resetGenerationSettings": "Reset Generation Settings",
|
||||
"replaceCurrent": "Replace Current",
|
||||
"controlLayerEmptyState": "<UploadButton>Upload an image</UploadButton>, drag an image from the <GalleryButton>gallery</GalleryButton> onto this layer, or draw on the canvas to get started.",
|
||||
"referenceImageEmptyState": "<UploadButton>Upload an image</UploadButton> or drag an image from the <GalleryButton>gallery</GalleryButton> onto this layer to get started.",
|
||||
"referenceImageEmptyState": "<UploadButton>Upload an image</UploadButton>, drag an image from the <GalleryButton>gallery</GalleryButton> onto this layer, or <PullBboxButton>pull the bounding box into this layer</PullBboxButton> to get started.",
|
||||
"warnings": {
|
||||
"problemsFound": "Problems found",
|
||||
"unsupportedModel": "layer not supported for selected base model",
|
||||
@@ -2252,12 +2306,8 @@
|
||||
"whatsNew": {
|
||||
"whatsNewInInvoke": "What's New in Invoke",
|
||||
"items": [
|
||||
"Improved VRAM setting defaults",
|
||||
"On-demand model cache clearing",
|
||||
"Expanded FLUX LoRA compatibility",
|
||||
"Canvas Adjust Image filter",
|
||||
"Cancel all but current queue item",
|
||||
"Copy from and paste to Canvas"
|
||||
"Memory Management: New setting for users with Nvidia GPUs to reduce VRAM usage.",
|
||||
"Performance: Continued improvements to overall application performance and responsiveness."
|
||||
],
|
||||
"readReleaseNotes": "Read Release Notes",
|
||||
"watchRecentReleaseVideos": "Watch Recent Release Videos",
|
||||
|
||||
@@ -98,7 +98,22 @@
|
||||
"close": "Fermer",
|
||||
"clipboard": "Presse-papier",
|
||||
"loadingModel": "Chargement du modèle",
|
||||
"generating": "En Génération"
|
||||
"generating": "En Génération",
|
||||
"warnings": "Alertes",
|
||||
"layout": "Disposition",
|
||||
"row": "Ligne",
|
||||
"column": "Colonne",
|
||||
"start": "Commencer",
|
||||
"board": "Planche",
|
||||
"count": "Quantité",
|
||||
"step": "Étape",
|
||||
"end": "Fin",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"values": "Valeurs",
|
||||
"resetToDefaults": "Réinitialiser par défaut",
|
||||
"seed": "Graine",
|
||||
"combinatorial": "Combinatoire"
|
||||
},
|
||||
"gallery": {
|
||||
"galleryImageSize": "Taille de l'image",
|
||||
@@ -165,7 +180,9 @@
|
||||
"imagesSettings": "Paramètres des images de la galerie",
|
||||
"assetsTab": "Fichiers que vous avez importés pour vos projets.",
|
||||
"imagesTab": "Images que vous avez créées et enregistrées dans Invoke.",
|
||||
"boardsSettings": "Paramètres des planches"
|
||||
"boardsSettings": "Paramètres des planches",
|
||||
"assets": "Ressources",
|
||||
"images": "Images"
|
||||
},
|
||||
"modelManager": {
|
||||
"modelManager": "Gestionnaire de modèle",
|
||||
@@ -289,7 +306,7 @@
|
||||
"usingDefaultSettings": "Utilisation des paramètres par défaut du modèle",
|
||||
"defaultSettingsOutOfSync": "Certain paramètres ne correspondent pas aux valeurs par défaut du modèle :",
|
||||
"restoreDefaultSettings": "Cliquez pour utiliser les paramètres par défaut du modèle.",
|
||||
"hfForbiddenErrorMessage": "Nous vous recommandons de visiter la page du modèle sur HuggingFace.com. Le propriétaire peut exiger l'acceptation des conditions pour pouvoir télécharger.",
|
||||
"hfForbiddenErrorMessage": "Nous vous recommandons de visiter la page du modèle. Le propriétaire peut exiger l'acceptation des conditions pour pouvoir télécharger.",
|
||||
"hfTokenRequired": "Vous essayez de télécharger un modèle qui nécessite un token HuggingFace valide.",
|
||||
"clipLEmbed": "CLIP-L Embed",
|
||||
"hfTokenSaved": "Token HF enregistré",
|
||||
@@ -301,7 +318,12 @@
|
||||
"hfTokenHelperText": "Un token HF est requis pour utiliser certains modèles. Cliquez ici pour créer ou obtenir votre token.",
|
||||
"hfTokenInvalid": "Token HF invalide ou manquant",
|
||||
"hfForbidden": "Vous n'avez pas accès à ce modèle HF.",
|
||||
"hfTokenInvalidErrorMessage2": "Mettre à jour dans le "
|
||||
"hfTokenInvalidErrorMessage2": "Mettre à jour dans le ",
|
||||
"controlLora": "Controle LoRA",
|
||||
"urlUnauthorizedErrorMessage2": "Découvrir comment ici.",
|
||||
"urlUnauthorizedErrorMessage": "Vous devrez peut-être configurer un jeton API pour accéder à ce modèle.",
|
||||
"urlForbidden": "Vous n'avez pas accès à ce modèle",
|
||||
"urlForbiddenErrorMessage": "Vous devrez peut-être demander l'autorisation du site qui distribue le modèle."
|
||||
},
|
||||
"parameters": {
|
||||
"images": "Images",
|
||||
@@ -332,7 +354,7 @@
|
||||
"showOptionsPanel": "Afficher le panneau latéral (O ou T)",
|
||||
"invoke": {
|
||||
"noPrompts": "Aucun prompts généré",
|
||||
"missingInputForField": "{{nodeLabel}} -> {{fieldLabel}} entrée manquante",
|
||||
"missingInputForField": "entrée manquante",
|
||||
"missingFieldTemplate": "Modèle de champ manquant",
|
||||
"invoke": "Invoke",
|
||||
"addingImagesTo": "Ajouter des images à",
|
||||
@@ -343,17 +365,31 @@
|
||||
"fluxModelIncompatibleBboxHeight": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), la hauteur de la bounding box est {{height}}",
|
||||
"fluxModelIncompatibleScaledBboxHeight": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), la hauteur de la bounding box est {{height}}",
|
||||
"noFLUXVAEModelSelected": "Aucun modèle VAE sélectionné pour la génération FLUX",
|
||||
"canvasIsTransforming": "La Toile se transforme",
|
||||
"canvasIsRasterizing": "La Toile se rastérise",
|
||||
"canvasIsTransforming": "La Toile est occupée (en transformation)",
|
||||
"canvasIsRasterizing": "La Toile est occupée (en rastérisation)",
|
||||
"noCLIPEmbedModelSelected": "Aucun modèle CLIP Embed sélectionné pour la génération FLUX",
|
||||
"canvasIsFiltering": "La Toile est en train de filtrer",
|
||||
"canvasIsFiltering": "La Toile est occupée (en filtration)",
|
||||
"fluxModelIncompatibleBboxWidth": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), la largeur de la bounding box est {{width}}",
|
||||
"noT5EncoderModelSelected": "Aucun modèle T5 Encoder sélectionné pour la génération FLUX",
|
||||
"fluxModelIncompatibleScaledBboxWidth": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), la largeur de la bounding box mise à l'échelle est {{width}}",
|
||||
"canvasIsCompositing": "La toile est en train de composer",
|
||||
"collectionTooFewItems": "{{nodeLabel}} -> {{fieldLabel}} : trop peu d'éléments, minimum {{minItems}}",
|
||||
"collectionTooManyItems": "{{nodeLabel}} -> {{fieldLabel}} : trop d'éléments, maximum {{maxItems}}",
|
||||
"canvasIsSelectingObject": "La toile est occupée (sélection d'objet)"
|
||||
"canvasIsCompositing": "La Toile est occupée (en composition)",
|
||||
"collectionTooFewItems": "trop peu d'éléments, minimum {{minItems}}",
|
||||
"collectionTooManyItems": "trop d'éléments, maximum {{maxItems}}",
|
||||
"canvasIsSelectingObject": "La toile est occupée (sélection d'objet)",
|
||||
"emptyBatches": "lots vides",
|
||||
"batchNodeNotConnected": "Noeud de lots non connecté : {{label}}",
|
||||
"fluxModelMultipleControlLoRAs": "Vous ne pouvez utiliser qu'un seul Control LoRA à la fois",
|
||||
"collectionNumberLTMin": "{{value}} < {{minimum}} (incl. min)",
|
||||
"collectionNumberGTMax": "{{value}} > {{maximum}} (incl. max)",
|
||||
"collectionNumberGTExclusiveMax": "{{value}} >= {{exclusiveMaximum}} (max exc)",
|
||||
"batchNodeEmptyCollection": "Certains nœuds de lot ont des collections vides",
|
||||
"batchNodeCollectionSizeMismatch": "Non-concordance de taille de collection sur le lot {{batchGroupId}}",
|
||||
"collectionStringTooLong": "trop long, max {{maxLength}}",
|
||||
"collectionNumberNotMultipleOf": "{{value}} n'est pas un multiple de {{multipleOf}}",
|
||||
"collectionEmpty": "collection vide",
|
||||
"collectionStringTooShort": "trop court, min {{minLength}}",
|
||||
"collectionNumberLTExclusiveMin": "{{value}} <= {{exclusiveMinimum}} (min exc)",
|
||||
"batchNodeCollectionSizeMismatchNoGroupId": "Taille de collection de groupe par lot non conforme"
|
||||
},
|
||||
"negativePromptPlaceholder": "Prompt Négatif",
|
||||
"positivePromptPlaceholder": "Prompt Positif",
|
||||
@@ -497,7 +533,13 @@
|
||||
"uploadFailedInvalidUploadDesc_withCount_one": "Doit être au maximum une image PNG ou JPEG.",
|
||||
"uploadFailedInvalidUploadDesc_withCount_many": "Doit être au maximum {{count}} images PNG ou JPEG.",
|
||||
"uploadFailedInvalidUploadDesc_withCount_other": "Doit être au maximum {{count}} images PNG ou JPEG.",
|
||||
"addedToUncategorized": "Ajouté aux ressources de la planche $t(boards.uncategorized)"
|
||||
"addedToUncategorized": "Ajouté aux ressources de la planche $t(boards.uncategorized)",
|
||||
"pasteSuccess": "Collé à {{destination}}",
|
||||
"pasteFailed": "Échec du collage",
|
||||
"outOfMemoryErrorDescLocal": "Suivez notre <LinkComponent>guide Low VRAM</LinkComponent> pour réduire les OOMs.",
|
||||
"unableToCopy": "Incapable de Copier",
|
||||
"unableToCopyDesc": "Votre navigateur ne prend pas en charge l'accès au presse-papiers. Les utilisateurs de Firefox peuvent peut-être résoudre ce problème en suivant ",
|
||||
"unableToCopyDesc_theseSteps": "ces étapes"
|
||||
},
|
||||
"accessibility": {
|
||||
"uploadImage": "Importer une image",
|
||||
@@ -655,7 +697,14 @@
|
||||
"iterations_many": "Itérations",
|
||||
"iterations_other": "Itérations",
|
||||
"back": "fin",
|
||||
"batchSize": "Taille de lot"
|
||||
"batchSize": "Taille de lot",
|
||||
"retryFailed": "Problème de nouvelle tentative de l'élément",
|
||||
"retrySucceeded": "Élément Retenté",
|
||||
"retryItem": "Réessayer l'élement",
|
||||
"cancelAllExceptCurrentQueueItemAlertDialog": "Annuler tous les éléments de la file d'attente, sauf celui en cours, arrêtera les éléments en attente mais permettra à celui en cours de se terminer.",
|
||||
"cancelAllExceptCurrentQueueItemAlertDialog2": "Êtes-vous sûr de vouloir annuler tous les éléments en attente dans la file d'attente ?",
|
||||
"cancelAllExceptCurrentTooltip": "Annuler tout sauf l'élément actuel",
|
||||
"confirm": "Confirmer"
|
||||
},
|
||||
"prompt": {
|
||||
"noMatchingTriggers": "Pas de déclancheurs correspondants",
|
||||
@@ -1027,7 +1076,9 @@
|
||||
"controlNetWeight": {
|
||||
"heading": "Poids",
|
||||
"paragraphs": [
|
||||
"Poids du Control Adapter. Un poids plus élevé aura un impact plus important sur l'image finale."
|
||||
"Poids du Control Adapter. Un poids plus élevé aura un impact plus important sur l'image finale.",
|
||||
"• Poids plus élevé (.75-2) : Crée un impact plus significatif sur le résultat final.",
|
||||
"• Poids inférieur (0-.75) : Crée un impact plus faible sur le résultat final."
|
||||
]
|
||||
},
|
||||
"compositingMaskAdjustments": {
|
||||
@@ -1072,8 +1123,9 @@
|
||||
"controlNetBeginEnd": {
|
||||
"heading": "Pourcentage de début / de fin d'étape",
|
||||
"paragraphs": [
|
||||
"La partie du processus de débruitage à laquelle le Control Adapter sera appliqué.",
|
||||
"En général, les Control Adapter appliqués au début du processus guident la composition, tandis que les Control Adapter appliqués à la fin guident les détails."
|
||||
"Ce paramètre détérmine quelle portion du processus de débruitage (génération) utilisera cette couche comme guide.",
|
||||
"En général, les Control Adapter appliqués au début du processus guident la composition, tandis que les Control Adapter appliqués à la fin guident les détails.",
|
||||
"• Étape de fin (%): Spécifie quand arrêter d'appliquer le guide de cette couche et revenir aux guides généraux du modèle et aux autres paramètres."
|
||||
]
|
||||
},
|
||||
"controlNetControlMode": {
|
||||
@@ -1438,7 +1490,8 @@
|
||||
"showDynamicPrompts": "Afficher les Prompts dynamiques",
|
||||
"dynamicPrompts": "Prompts Dynamiques",
|
||||
"promptsPreview": "Prévisualisation des Prompts",
|
||||
"loading": "Génération des Pompts Dynamiques..."
|
||||
"loading": "Génération des Pompts Dynamiques...",
|
||||
"promptsToGenerate": "Prompts à générer"
|
||||
},
|
||||
"metadata": {
|
||||
"positivePrompt": "Prompt Positif",
|
||||
@@ -1630,7 +1683,41 @@
|
||||
"boardAccessError": "Impossible de trouver la planche {{board_id}}, réinitialisation à la valeur par défaut",
|
||||
"workflowHelpText": "Besoin d'aide ? Consultez notre guide sur <LinkComponent>Comment commencer avec les Workflows</LinkComponent>.",
|
||||
"noWorkflows": "Aucun Workflows",
|
||||
"noMatchingWorkflows": "Aucun Workflows correspondant"
|
||||
"noMatchingWorkflows": "Aucun Workflows correspondant",
|
||||
"arithmeticSequence": "Séquence Arithmétique",
|
||||
"uniformRandomDistribution": "Distribution Aléatoire Uniforme",
|
||||
"noBatchGroup": "aucun groupe",
|
||||
"generatorLoading": "chargement",
|
||||
"generatorLoadFromFile": "Charger depuis un Fichier",
|
||||
"dynamicPromptsRandom": "Prompts Dynamiques (Aléatoire)",
|
||||
"integerRangeGenerator": "Générateur d'interval d'entiers",
|
||||
"generateValues": "Générer Valeurs",
|
||||
"linearDistribution": "Distribution Linéaire",
|
||||
"floatRangeGenerator": "Générateur d'interval de nombres décimaux",
|
||||
"generatorNRandomValues_one": "{{count}} valeur aléatoire",
|
||||
"generatorNRandomValues_many": "{{count}} valeurs aléatoires",
|
||||
"generatorNRandomValues_other": "{{count}} valeurs aléatoires",
|
||||
"dynamicPromptsCombinatorial": "Prompts Dynamiques (Combinatoire)",
|
||||
"parseString": "Analyser la chaine de charactères",
|
||||
"internalDesc": "Cette invocation est utilisée internalement par Invoke. En fonction des mises à jours il est possible que des changements y soit effectués ou qu'elle soit supprimé sans prévention.",
|
||||
"splitOn": "Diviser sur",
|
||||
"generatorNoValues": "vide",
|
||||
"addItem": "Ajouter un élément",
|
||||
"specialDesc": "Cette invocation nécessite un traitement spécial dans l'application. Par exemple, les nœuds Batch sont utilisés pour mettre en file d'attente plusieurs graphes à partir d'un seul workflow.",
|
||||
"unableToUpdateNode": "La mise à jour du nœud a échoué : nœud {{node}} de type {{type}} (peut nécessiter la suppression et la recréation).",
|
||||
"deletedMissingNodeFieldFormElement": "Champ de formulaire manquant supprimé : nœud {{nodeId}} champ {{fieldName}}",
|
||||
"nodeName": "Nom du nœud",
|
||||
"description": "Description",
|
||||
"loadWorkflowDesc": "Charger le workflow ?",
|
||||
"missingSourceOrTargetNode": "Nœud source ou cible manquant",
|
||||
"generatorImagesCategory": "Catégorie",
|
||||
"generatorImagesFromBoard": "Images de la Planche",
|
||||
"missingSourceOrTargetHandle": "Manque de gestionnaire source ou cible",
|
||||
"loadingTemplates": "Chargement de {{name}}",
|
||||
"loadWorkflowDesc2": "Votre workflow actuel contient des modifications non enregistrées.",
|
||||
"generatorImages_one": "{{count}} image",
|
||||
"generatorImages_many": "{{count}} images",
|
||||
"generatorImages_other": "{{count}} images"
|
||||
},
|
||||
"models": {
|
||||
"noMatchingModels": "Aucun modèle correspondant",
|
||||
@@ -1689,13 +1776,41 @@
|
||||
"deleteWorkflow2": "Êtes-vous sûr de vouloir supprimer ce Workflow ? Cette action ne peut pas être annulé.",
|
||||
"download": "Télécharger",
|
||||
"copyShareLinkForWorkflow": "Copier le lien de partage pour le Workflow",
|
||||
"delete": "Supprimer"
|
||||
"delete": "Supprimer",
|
||||
"builder": {
|
||||
"component": "Composant",
|
||||
"numberInput": "Entrée de nombre",
|
||||
"slider": "Curseur",
|
||||
"both": "Les deux",
|
||||
"singleLine": "Ligne unique",
|
||||
"multiLine": "Multi Ligne",
|
||||
"headingPlaceholder": "En-tête vide",
|
||||
"emptyRootPlaceholderEditMode": "Faites glisser un élément de formulaire ou un champ de nœud ici pour commencer.",
|
||||
"emptyRootPlaceholderViewMode": "Cliquez sur Modifier pour commencer à créer un formulaire pour ce workflow.",
|
||||
"containerPlaceholder": "Conteneur Vide",
|
||||
"row": "Ligne",
|
||||
"column": "Colonne",
|
||||
"layout": "Mise en page",
|
||||
"nodeField": "Champ de nœud",
|
||||
"zoomToNode": "Zoomer sur le nœud",
|
||||
"nodeFieldTooltip": "Pour ajouter un champ de nœud, cliquez sur le petit bouton plus sur le champ dans l'Éditeur de Workflow, ou faites glisser le champ par son nom dans le formulaire.",
|
||||
"addToForm": "Ajouter au formulaire",
|
||||
"label": "Étiquette",
|
||||
"textPlaceholder": "Texte vide",
|
||||
"builder": "Constructeur de Formulaire",
|
||||
"resetAllNodeFields": "Réinitialiser tous les champs de nœud",
|
||||
"deleteAllElements": "Supprimer tous les éléments de formulaire",
|
||||
"workflowBuilderAlphaWarning": "Le constructeur de workflow est actuellement en version alpha. Il peut y avoir des changements majeurs avant la version stable.",
|
||||
"showDescription": "Afficher la description"
|
||||
},
|
||||
"openLibrary": "Ouvrir la Bibliothèque"
|
||||
},
|
||||
"whatsNew": {
|
||||
"whatsNewInInvoke": "Quoi de neuf dans Invoke",
|
||||
"watchRecentReleaseVideos": "Regarder les vidéos des dernières versions",
|
||||
"items": [
|
||||
"<StrongComponent>FLUX Guidage Régional (bêta)</StrongComponent> : Notre version bêta de FLUX Guidage Régional est en ligne pour le contrôle des prompt régionaux."
|
||||
"<StrongComponent>FLUX Guidage Régional (bêta)</StrongComponent> : Notre version bêta de FLUX Guidage Régional est en ligne pour le contrôle des prompt régionaux.",
|
||||
"Autres améliorations : mise en file d'attente par lots plus rapide, meilleur redimensionnement, sélecteur de couleurs amélioré et nœuds de métadonnées."
|
||||
],
|
||||
"readReleaseNotes": "Notes de version",
|
||||
"watchUiUpdatesOverview": "Aperçu des mises à jour de l'interface utilisateur"
|
||||
@@ -1809,7 +1924,49 @@
|
||||
"cancel": "Annuler",
|
||||
"advanced": "Avancé",
|
||||
"processingLayerWith": "Calque de traitement avec le filtre {{type}}.",
|
||||
"forMoreControl": "Pour plus de contrôle, cliquez sur Avancé ci-dessous."
|
||||
"forMoreControl": "Pour plus de contrôle, cliquez sur Avancé ci-dessous.",
|
||||
"adjust_image": {
|
||||
"b": "B (LAB)",
|
||||
"blue": "Bleu (RGBA)",
|
||||
"alpha": "Alpha (RGBA)",
|
||||
"magenta": "Magenta (CMJN)",
|
||||
"yellow": "Jaune (CMJN)",
|
||||
"cb": "Cb (YCbCr)",
|
||||
"cr": "Cr (YCbCr)",
|
||||
"cyan": "Cyan (CMJN)",
|
||||
"label": "Ajuster l'image",
|
||||
"description": "Ajuste le canal sélectionné d'une image.",
|
||||
"channel": "Canal",
|
||||
"value_setting": "Valeur",
|
||||
"scale_values": "Valeurs d'échelle",
|
||||
"red": "Rouge (RGBA)",
|
||||
"green": "Vert (RGBA)",
|
||||
"black": "Noir (CMJN)",
|
||||
"hue": "Teinte (HSV)",
|
||||
"saturation": "Saturation (HSV)",
|
||||
"value": "Valeur (HSV)",
|
||||
"luminosity": "Luminosité (LAB)",
|
||||
"a": "A (LAB)",
|
||||
"y": "Y (YCbCr)"
|
||||
},
|
||||
"img_blur": {
|
||||
"label": "Flou de l'image",
|
||||
"blur_type": "Type de flou",
|
||||
"box_type": "Boîte",
|
||||
"description": "Floute la couche sélectionnée.",
|
||||
"blur_radius": "Rayon",
|
||||
"gaussian_type": "Gaussien"
|
||||
},
|
||||
"img_noise": {
|
||||
"label": "Image de bruit",
|
||||
"description": "Ajoute du bruit à la couche sélectionnée.",
|
||||
"gaussian_type": "Gaussien",
|
||||
"size": "Taille du bruit",
|
||||
"noise_amount": "Quantité",
|
||||
"noise_type": "Type de bruit",
|
||||
"salt_and_pepper_type": "Sel et Poivre",
|
||||
"noise_color": "Bruit coloré"
|
||||
}
|
||||
},
|
||||
"canvasContextMenu": {
|
||||
"saveToGalleryGroup": "Enregistrer dans la galerie",
|
||||
@@ -1823,7 +1980,10 @@
|
||||
"newGlobalReferenceImage": "Nouvelle image de référence globale",
|
||||
"newControlLayer": "Nouveau couche de contrôle",
|
||||
"newInpaintMask": "Nouveau Masque Inpaint",
|
||||
"newRegionalGuidance": "Nouveau Guide Régional"
|
||||
"newRegionalGuidance": "Nouveau Guide Régional",
|
||||
"copyToClipboard": "Copier dans le presse-papiers",
|
||||
"copyBboxToClipboard": "Copier Bbox dans le presse-papiers",
|
||||
"copyCanvasToClipboard": "Copier la Toile dans le presse-papiers"
|
||||
},
|
||||
"bookmark": "Marque-page pour Changement Rapide",
|
||||
"saveLayerToAssets": "Enregistrer la couche dans les ressources",
|
||||
@@ -1989,7 +2149,10 @@
|
||||
"ipAdapterMethod": "Méthode d'IP Adapter",
|
||||
"full": "Complet",
|
||||
"style": "Style uniquement",
|
||||
"composition": "Composition uniquement"
|
||||
"composition": "Composition uniquement",
|
||||
"fullDesc": "Applique le style visuel (couleurs, textures) et la composition (mise en page, structure).",
|
||||
"styleDesc": "Applique un style visuel (couleurs, textures) sans tenir compte de sa mise en page.",
|
||||
"compositionDesc": "Réplique la mise en page et la structure tout en ignorant le style de la référence."
|
||||
},
|
||||
"fitBboxToLayers": "Ajuster la bounding box aux calques",
|
||||
"regionIsEmpty": "La zone sélectionnée est vide",
|
||||
@@ -2072,7 +2235,40 @@
|
||||
"asRasterLayerResize": "En tant que $t(controlLayers.rasterLayer) (Redimensionner)",
|
||||
"asControlLayer": "En tant que $t(controlLayers.controlLayer)",
|
||||
"asControlLayerResize": "En $t(controlLayers.controlLayer) (Redimensionner)",
|
||||
"newSession": "Nouvelle session"
|
||||
"newSession": "Nouvelle session",
|
||||
"warnings": {
|
||||
"controlAdapterIncompatibleBaseModel": "modèle de base de la couche de contrôle incompatible",
|
||||
"controlAdapterNoControl": "aucun contrôle sélectionné/dessiné",
|
||||
"rgNoPromptsOrIPAdapters": "pas de textes d'instructions ni d'images de référence",
|
||||
"rgAutoNegativeNotSupported": "Auto-négatif non pris en charge pour le modèle de base sélectionné",
|
||||
"rgNoRegion": "aucune région dessinée",
|
||||
"ipAdapterNoModelSelected": "aucun modèle d'image de référence sélectionné",
|
||||
"rgReferenceImagesNotSupported": "Les images de référence régionales ne sont pas prises en charge pour le modèle de base sélectionné",
|
||||
"problemsFound": "Problèmes trouvés",
|
||||
"unsupportedModel": "couche non prise en charge pour le modèle de base sélectionné",
|
||||
"rgNegativePromptNotSupported": "Prompt négatif non pris en charge pour le modèle de base sélectionné",
|
||||
"ipAdapterIncompatibleBaseModel": "modèle de base d'image de référence incompatible",
|
||||
"controlAdapterNoModelSelected": "aucun modèle de couche de contrôle sélectionné",
|
||||
"ipAdapterNoImageSelected": "Aucune image de référence sélectionnée."
|
||||
},
|
||||
"pasteTo": "Coller vers",
|
||||
"pasteToAssets": "Ressources",
|
||||
"pasteToAssetsDesc": "Coller dans les ressources",
|
||||
"pasteToBbox": "Bbox",
|
||||
"regionCopiedToClipboard": "{{region}} Copié dans le presse-papiers",
|
||||
"copyRegionError": "Erreur de copie {{region}}",
|
||||
"pasteToCanvas": "Toile",
|
||||
"errors": {
|
||||
"unableToFindImage": "Impossible de trouver l'image",
|
||||
"unableToLoadImage": "Impossible de charger l'image"
|
||||
},
|
||||
"referenceImageRegional": "Image de référence (régionale)",
|
||||
"pasteToBboxDesc": "Nouvelle couche (dans Bbox)",
|
||||
"pasteToCanvasDesc": "Nouvelle couche (dans la Toile)",
|
||||
"useImage": "Utiliser l'image",
|
||||
"pastedTo": "Collé à {{destination}}",
|
||||
"referenceImageEmptyState": "<UploadButton>Séléctionner une image</UploadButton> ou faites glisser une image depuis la <GalleryButton>galerie</GalleryButton> sur cette couche pour commencer.",
|
||||
"referenceImageGlobal": "Image de référence (Globale)"
|
||||
},
|
||||
"upscaling": {
|
||||
"exceedsMaxSizeDetails": "La limite maximale d'agrandissement est de {{maxUpscaleDimension}}x{{maxUpscaleDimension}} pixels. Veuillez essayer une image plus petite ou réduire votre sélection d'échelle.",
|
||||
@@ -2152,7 +2348,8 @@
|
||||
"queue": "File d'attente",
|
||||
"events": "Événements",
|
||||
"metadata": "Métadonnées",
|
||||
"gallery": "Galerie"
|
||||
"gallery": "Galerie",
|
||||
"dnd": "Glisser et déposer"
|
||||
},
|
||||
"logLevel": {
|
||||
"trace": "Trace",
|
||||
@@ -2169,7 +2366,8 @@
|
||||
"toGetStarted": "Pour commencer, saisissez un prompt dans la boîte et cliquez sur <StrongComponent>Invoke</StrongComponent> pour générer votre première image. Sélectionnez un template de prompt pour améliorer les résultats. Vous pouvez choisir de sauvegarder vos images directement dans la <StrongComponent>Galerie</StrongComponent> ou de les modifier sur la <StrongComponent>Toile</StrongComponent>.",
|
||||
"gettingStartedSeries": "Vous souhaitez plus de conseils ? Consultez notre <LinkComponent>Série de démarrage</LinkComponent> pour des astuces sur l'exploitation du plein potentiel de l'Invoke Studio.",
|
||||
"noModelsInstalled": "Il semble qu'aucun modèle ne soit installé",
|
||||
"toGetStartedLocal": "Pour commencer, assurez-vous de télécharger ou d'importer des modèles nécessaires pour exécuter Invoke. Ensuite, saisissez le prompt dans la boîte et cliquez sur <StrongComponent>Invoke</StrongComponent> pour générer votre première image. Sélectionnez un template de prompt pour améliorer les résultats. Vous pouvez choisir de sauvegarder vos images directement sur <StrongComponent>Galerie</StrongComponent> ou les modifier sur la <StrongComponent>Toile</StrongComponent>."
|
||||
"toGetStartedLocal": "Pour commencer, assurez-vous de télécharger ou d'importer des modèles nécessaires pour exécuter Invoke. Ensuite, saisissez le prompt dans la boîte et cliquez sur <StrongComponent>Invoke</StrongComponent> pour générer votre première image. Sélectionnez un template de prompt pour améliorer les résultats. Vous pouvez choisir de sauvegarder vos images directement sur <StrongComponent>Galerie</StrongComponent> ou les modifier sur la <StrongComponent>Toile</StrongComponent>.",
|
||||
"lowVRAMMode": "Pour de meilleures performances, suivez notre <LinkComponent>guide Low VRAM</LinkComponent>."
|
||||
},
|
||||
"upsell": {
|
||||
"shareAccess": "Partager l'accès",
|
||||
@@ -2217,7 +2415,8 @@
|
||||
"description": "Introduction à l'ajout d'images de référence et IP Adapters globaux."
|
||||
},
|
||||
"howDoIUseInpaintMasks": {
|
||||
"title": "Comment utiliser les masques d'inpainting ?"
|
||||
"title": "Comment utiliser les masques d'inpainting ?",
|
||||
"description": "Comment appliquer des masques de retourche pour la correction et la variation d'image."
|
||||
},
|
||||
"creatingYourFirstImage": {
|
||||
"title": "Créer votre première image",
|
||||
@@ -2237,5 +2436,10 @@
|
||||
"studioSessionsDesc2": "Rejoignez notre <DiscordLink /> pour participer aux sessions en direct et poser vos questions. Les sessions sont ajoutée dans la playlist la semaine suivante.",
|
||||
"supportVideos": "Vidéos d'assistance",
|
||||
"controlCanvas": "Contrôler la toile"
|
||||
},
|
||||
"modelCache": {
|
||||
"clear": "Effacer le cache du modèle",
|
||||
"clearSucceeded": "Cache du modèle effacée",
|
||||
"clearFailed": "Problème de nettoyage du cache du modèle"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,11 @@
|
||||
"resetToDefaults": "Ripristina le impostazioni predefinite",
|
||||
"seed": "Seme",
|
||||
"combinatorial": "Combinatorio",
|
||||
"count": "Quantità"
|
||||
"count": "Quantità",
|
||||
"board": "Bacheca",
|
||||
"layout": "Schema",
|
||||
"row": "Riga",
|
||||
"column": "Colonna"
|
||||
},
|
||||
"gallery": {
|
||||
"galleryImageSize": "Dimensione dell'immagine",
|
||||
@@ -172,7 +176,9 @@
|
||||
"imagesTab": "Immagini create e salvate in Invoke.",
|
||||
"assetsTab": "File che hai caricato per usarli nei tuoi progetti.",
|
||||
"boardsSettings": "Impostazioni Bacheche",
|
||||
"imagesSettings": "Impostazioni Immagini Galleria"
|
||||
"imagesSettings": "Impostazioni Immagini Galleria",
|
||||
"assets": "Risorse",
|
||||
"images": "Immagini"
|
||||
},
|
||||
"hotkeys": {
|
||||
"searchHotkeys": "Cerca tasti di scelta rapida",
|
||||
@@ -698,12 +704,12 @@
|
||||
"batchNodeEmptyCollection": "Alcuni nodi lotto hanno raccolte vuote",
|
||||
"emptyBatches": "lotti vuoti",
|
||||
"batchNodeCollectionSizeMismatch": "Le dimensioni della raccolta nel Lotto {{batchGroupId}} non corrispondono",
|
||||
"invalidBatchConfigurationCannotCalculate": "Configurazione lotto non valida; impossibile calcolare",
|
||||
"collectionStringTooShort": "troppo corto, minimo {{minLength}}",
|
||||
"collectionNumberNotMultipleOf": "{{value}} non è multiplo di {{multipleOf}}",
|
||||
"collectionNumberLTMin": "{{value}} < {{minimum}} (incr min)",
|
||||
"collectionNumberGTExclusiveMax": "{{value}} >= {{exclusiveMaximum}} (excl max)",
|
||||
"collectionNumberLTExclusiveMin": "{{value}} <= {{exclusiveMinimum}} (excl min)"
|
||||
"collectionNumberLTExclusiveMin": "{{value}} <= {{exclusiveMinimum}} (excl min)",
|
||||
"collectionEmpty": "raccolta vuota"
|
||||
},
|
||||
"useCpuNoise": "Usa la CPU per generare rumore",
|
||||
"iterations": "Iterazioni",
|
||||
@@ -832,7 +838,12 @@
|
||||
"uploadFailedInvalidUploadDesc_withCount_one": "Devi caricare al massimo 1 immagine PNG o JPEG.",
|
||||
"uploadFailedInvalidUploadDesc_withCount_many": "Devi caricare al massimo {{count}} immagini PNG o JPEG.",
|
||||
"uploadFailedInvalidUploadDesc_withCount_other": "Devi caricare al massimo {{count}} immagini PNG o JPEG.",
|
||||
"outOfMemoryErrorDescLocal": "Segui la nostra <LinkComponent>guida per bassa VRAM</LinkComponent> per ridurre gli OOM."
|
||||
"outOfMemoryErrorDescLocal": "Segui la nostra <LinkComponent>guida per bassa VRAM</LinkComponent> per ridurre gli OOM.",
|
||||
"pasteFailed": "Incolla non riuscita",
|
||||
"pasteSuccess": "Incollato su {{destination}}",
|
||||
"unableToCopy": "Impossibile copiare",
|
||||
"unableToCopyDesc": "Il tuo browser non supporta l'accesso agli appunti. Gli utenti di Firefox potrebbero risolvere il problema seguendo ",
|
||||
"unableToCopyDesc_theseSteps": "questi passaggi"
|
||||
},
|
||||
"accessibility": {
|
||||
"invokeProgressBar": "Barra di avanzamento generazione",
|
||||
@@ -915,8 +926,8 @@
|
||||
"boolean": "Booleani",
|
||||
"node": "Nodo",
|
||||
"collection": "Raccolta",
|
||||
"cannotConnectInputToInput": "Impossibile collegare Input a Input",
|
||||
"cannotConnectOutputToOutput": "Impossibile collegare Output ad Output",
|
||||
"cannotConnectInputToInput": "Impossibile collegare ingresso a ingresso",
|
||||
"cannotConnectOutputToOutput": "Impossibile collegare uscita ad uscita",
|
||||
"cannotConnectToSelf": "Impossibile connettersi a se stesso",
|
||||
"mismatchedVersion": "Nodo non valido: il nodo {{node}} di tipo {{type}} ha una versione non corrispondente (provare ad aggiornare?)",
|
||||
"loadingNodes": "Caricamento nodi...",
|
||||
@@ -1009,7 +1020,21 @@
|
||||
"generatorNRandomValues_one": "{{count}} valore casuale",
|
||||
"generatorNRandomValues_many": "{{count}} valori casuali",
|
||||
"generatorNRandomValues_other": "{{count}} valori casuali",
|
||||
"arithmeticSequence": "Sequenza aritmetica"
|
||||
"arithmeticSequence": "Sequenza aritmetica",
|
||||
"nodeName": "Nome del nodo",
|
||||
"loadWorkflowDesc": "Caricare il flusso di lavoro?",
|
||||
"loadWorkflowDesc2": "Il flusso di lavoro corrente presenta modifiche non salvate.",
|
||||
"downloadWorkflowError": "Errore durante lo scaricamento del flusso di lavoro",
|
||||
"deletedMissingNodeFieldFormElement": "Campo modulo mancante eliminato: nodo {{nodeId}} campo {{fieldName}}",
|
||||
"loadingTemplates": "Caricamento {{name}}",
|
||||
"unableToUpdateNode": "Aggiornamento del nodo non riuscito: nodo {{node}} di tipo {{type}} (potrebbe essere necessario eliminarlo e ricrearlo)",
|
||||
"description": "Descrizione",
|
||||
"generatorImagesCategory": "Categoria",
|
||||
"generatorImages_one": "{{count}} immagine",
|
||||
"generatorImages_many": "{{count}} immagini",
|
||||
"generatorImages_other": "{{count}} immagini",
|
||||
"generatorImagesFromBoard": "Immagini dalla Bacheca",
|
||||
"missingSourceOrTargetNode": "Nodo sorgente o di destinazione mancante"
|
||||
},
|
||||
"boards": {
|
||||
"autoAddBoard": "Aggiungi automaticamente bacheca",
|
||||
@@ -1135,7 +1160,10 @@
|
||||
"cancelAllExceptCurrentQueueItemAlertDialog2": "Vuoi davvero annullare tutti gli elementi in coda in sospeso?",
|
||||
"confirm": "Conferma",
|
||||
"cancelAllExceptCurrentQueueItemAlertDialog": "L'annullamento di tutti gli elementi della coda, eccetto quello corrente, interromperà gli elementi in sospeso ma consentirà il completamento di quello in corso.",
|
||||
"cancelAllExceptCurrentTooltip": "Annulla tutto tranne l'elemento corrente"
|
||||
"cancelAllExceptCurrentTooltip": "Annulla tutto tranne l'elemento corrente",
|
||||
"retrySucceeded": "Elemento rieseguito",
|
||||
"retryItem": "Riesegui elemento",
|
||||
"retryFailed": "Problema riesecuzione elemento"
|
||||
},
|
||||
"models": {
|
||||
"noMatchingModels": "Nessun modello corrispondente",
|
||||
@@ -1713,7 +1741,38 @@
|
||||
"download": "Scarica",
|
||||
"copyShareLink": "Copia Condividi Link",
|
||||
"copyShareLinkForWorkflow": "Copia Condividi Link del Flusso di lavoro",
|
||||
"delete": "Elimina"
|
||||
"delete": "Elimina",
|
||||
"openLibrary": "Apri la libreria",
|
||||
"builder": {
|
||||
"resetAllNodeFields": "Reimposta tutti i campi del nodo",
|
||||
"row": "Riga",
|
||||
"nodeField": "Campo del nodo",
|
||||
"slider": "Cursore",
|
||||
"emptyRootPlaceholderEditMode": "Per iniziare, trascina qui un elemento del modulo o un campo nodo.",
|
||||
"containerPlaceholder": "Contenitore vuoto",
|
||||
"headingPlaceholder": "Titolo vuoto",
|
||||
"column": "Colonna",
|
||||
"nodeFieldTooltip": "Per aggiungere un campo nodo, fare clic sul piccolo pulsante con il segno più sul campo nell'editor del flusso di lavoro, oppure trascinare il campo in base al suo nome nel modulo.",
|
||||
"label": "Etichetta",
|
||||
"deleteAllElements": "Elimina tutti gli elementi del modulo",
|
||||
"addToForm": "Aggiungi al Modulo",
|
||||
"layout": "Schema",
|
||||
"builder": "Generatore Modulo",
|
||||
"zoomToNode": "Zoom sul nodo",
|
||||
"component": "Componente",
|
||||
"showDescription": "Mostra Descrizione",
|
||||
"singleLine": "Linea singola",
|
||||
"multiLine": "Linea Multipla",
|
||||
"both": "Entrambi",
|
||||
"emptyRootPlaceholderViewMode": "Fare clic su Modifica per iniziare a creare un modulo per questo flusso di lavoro.",
|
||||
"textPlaceholder": "Testo vuoto",
|
||||
"workflowBuilderAlphaWarning": "Il generatore di flussi di lavoro è attualmente in versione alpha. Potrebbero esserci cambiamenti radicali prima della versione stabile.",
|
||||
"heading": "Intestazione",
|
||||
"divider": "Divisore",
|
||||
"container": "Contenitore",
|
||||
"text": "Testo",
|
||||
"numberInput": "Ingresso numerico"
|
||||
}
|
||||
},
|
||||
"accordions": {
|
||||
"compositing": {
|
||||
@@ -2092,7 +2151,10 @@
|
||||
"saveCanvasToGallery": "Salva la Tela nella Galleria",
|
||||
"saveToGalleryGroup": "Salva nella Galleria",
|
||||
"newInpaintMask": "Nuova maschera Inpaint",
|
||||
"newRegionalGuidance": "Nuova Guida Regionale"
|
||||
"newRegionalGuidance": "Nuova Guida Regionale",
|
||||
"copyToClipboard": "Copia negli appunti",
|
||||
"copyCanvasToClipboard": "Copia la tela negli appunti",
|
||||
"copyBboxToClipboard": "Copia il riquadro di delimitazione negli appunti"
|
||||
},
|
||||
"newImg2ImgCanvasFromImage": "Nuova Immagine da immagine",
|
||||
"copyRasterLayerTo": "Copia $t(controlLayers.rasterLayer) in",
|
||||
@@ -2132,7 +2194,7 @@
|
||||
"controlLayerEmptyState": "<UploadButton>Carica un'immagine</UploadButton>, trascina un'immagine dalla <GalleryButton>galleria</GalleryButton> su questo livello oppure disegna sulla tela per iniziare.",
|
||||
"useImage": "Usa immagine",
|
||||
"resetGenerationSettings": "Ripristina impostazioni di generazione",
|
||||
"referenceImageEmptyState": "Per iniziare, <UploadButton>carica un'immagine</UploadButton> oppure trascina un'immagine dalla <GalleryButton>galleria</GalleryButton> su questo livello.",
|
||||
"referenceImageEmptyState": "Per iniziare, <UploadButton>carica un'immagine</UploadButton>, trascina un'immagine dalla <GalleryButton>galleria</GalleryButton>, oppure <PullBboxButton>trascina il riquadro di delimitazione in questo livello</PullBboxButton> su questo livello.",
|
||||
"asRasterLayer": "Come $t(controlLayers.rasterLayer)",
|
||||
"asRasterLayerResize": "Come $t(controlLayers.rasterLayer) (Ridimensiona)",
|
||||
"asControlLayer": "Come $t(controlLayers.controlLayer)",
|
||||
@@ -2155,6 +2217,20 @@
|
||||
"ipAdapterIncompatibleBaseModel": "modello base dell'immagine di riferimento incompatibile",
|
||||
"ipAdapterNoImageSelected": "nessuna immagine di riferimento selezionata",
|
||||
"rgAutoNegativeNotSupported": "Auto-Negativo non supportato per il modello base selezionato"
|
||||
},
|
||||
"pasteTo": "Incolla su",
|
||||
"pasteToBboxDesc": "Nuovo livello (nel riquadro di delimitazione)",
|
||||
"pasteToAssets": "Risorse",
|
||||
"copyRegionError": "Errore durante la copia di {{region}}",
|
||||
"pasteToAssetsDesc": "Incolla in Risorse",
|
||||
"pasteToBbox": "Riquadro di delimitazione",
|
||||
"pasteToCanvas": "Tela",
|
||||
"pasteToCanvasDesc": "Nuovo livello (nella Tela)",
|
||||
"pastedTo": "Incollato su {{destination}}",
|
||||
"regionCopiedToClipboard": "{{region}} Copiato negli appunti",
|
||||
"errors": {
|
||||
"unableToFindImage": "Impossibile trovare l'immagine",
|
||||
"unableToLoadImage": "Impossibile caricare l'immagine"
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
@@ -2254,11 +2330,8 @@
|
||||
"watchRecentReleaseVideos": "Guarda i video su questa versione",
|
||||
"watchUiUpdatesOverview": "Guarda le novità dell'interfaccia",
|
||||
"items": [
|
||||
"Modalità Bassa-VRAM",
|
||||
"Gestione dinamica della memoria",
|
||||
"Tempi di caricamento del modello più rapidi",
|
||||
"Meno errori di memoria",
|
||||
"Funzionalità lotto del flusso di lavoro ampliate"
|
||||
"Editor del flusso di lavoro: nuovo generatore di moduli trascina-e-rilascia per una creazione più facile del flusso di lavoro.",
|
||||
"Altri miglioramenti: messa in coda dei lotti più rapida, migliore ampliamento, selettore colore migliorato e nodi metadati."
|
||||
]
|
||||
},
|
||||
"system": {
|
||||
|
||||
@@ -32,29 +32,29 @@
|
||||
"learnMore": "もっと学ぶ",
|
||||
"random": "ランダム",
|
||||
"batch": "バッチマネージャー",
|
||||
"advanced": "高度な設定",
|
||||
"advanced": "高度",
|
||||
"created": "作成済",
|
||||
"green": "緑",
|
||||
"blue": "青",
|
||||
"alpha": "アルファ",
|
||||
"outpaint": "アウトペイント",
|
||||
"outpaint": "outpaint",
|
||||
"unknown": "不明",
|
||||
"updated": "更新済",
|
||||
"add": "追加",
|
||||
"ai": "AI",
|
||||
"ai": "ai",
|
||||
"copyError": "$t(gallery.copy) エラー",
|
||||
"data": "データ",
|
||||
"template": "テンプレート",
|
||||
"red": "赤",
|
||||
"or": "または",
|
||||
"checkpoint": "チェックポイント",
|
||||
"checkpoint": "Checkpoint",
|
||||
"direction": "方向",
|
||||
"simple": "シンプル",
|
||||
"save": "保存",
|
||||
"saveAs": "名前をつけて保存",
|
||||
"somethingWentWrong": "何かの問題が発生しました",
|
||||
"details": "詳細",
|
||||
"inpaint": "インペイント",
|
||||
"inpaint": "inpaint",
|
||||
"delete": "削除",
|
||||
"nextPage": "次のページ",
|
||||
"copy": "コピー",
|
||||
@@ -70,12 +70,12 @@
|
||||
"unknownError": "未知のエラー",
|
||||
"orderBy": "並び順:",
|
||||
"enabled": "有効",
|
||||
"notInstalled": "未インストール",
|
||||
"notInstalled": "未 $t(common.installed)",
|
||||
"positivePrompt": "ポジティブプロンプト",
|
||||
"negativePrompt": "ネガティブプロンプト",
|
||||
"selected": "選択済み",
|
||||
"aboutDesc": "Invokeを業務で利用する場合はマークしてください:",
|
||||
"beta": "ベータ",
|
||||
"beta": "Beta",
|
||||
"disabled": "無効",
|
||||
"editor": "エディタ",
|
||||
"safetensors": "Safetensors",
|
||||
@@ -93,7 +93,27 @@
|
||||
"reset": "リセット",
|
||||
"none": "なし",
|
||||
"new": "新規",
|
||||
"close": "閉じる"
|
||||
"close": "閉じる",
|
||||
"warnings": "警告",
|
||||
"dontShowMeThese": "次回から表示しない",
|
||||
"goTo": "移動",
|
||||
"generating": "生成中",
|
||||
"loadingModel": "モデルをロード中",
|
||||
"layout": "レイアウト",
|
||||
"step": "ステップ",
|
||||
"start": "開始",
|
||||
"count": "回数",
|
||||
"end": "終了",
|
||||
"min": "最小",
|
||||
"max": "最大",
|
||||
"values": "値",
|
||||
"resetToDefaults": "デフォルトに戻す",
|
||||
"row": "行",
|
||||
"column": "列",
|
||||
"board": "ボード",
|
||||
"seed": "シード",
|
||||
"combinatorial": "組み合わせ",
|
||||
"aboutHeading": "想像力をこの手に"
|
||||
},
|
||||
"gallery": {
|
||||
"galleryImageSize": "画像のサイズ",
|
||||
@@ -109,7 +129,7 @@
|
||||
"currentlyInUse": "この画像は現在下記の機能を使用しています:",
|
||||
"drop": "ドロップ",
|
||||
"dropOrUpload": "$t(gallery.drop) またはアップロード",
|
||||
"deleteImage_other": "画像を削除",
|
||||
"deleteImage_other": "画像 {{count}} 枚を削除",
|
||||
"deleteImagePermanent": "削除された画像は復元できません。",
|
||||
"download": "ダウンロード",
|
||||
"unableToLoad": "ギャラリーをロードできません",
|
||||
@@ -155,7 +175,12 @@
|
||||
"displayBoardSearch": "ボード検索",
|
||||
"displaySearch": "画像を検索",
|
||||
"boardsSettings": "ボード設定",
|
||||
"imagesSettings": "ギャラリー画像設定"
|
||||
"imagesSettings": "ギャラリー画像設定",
|
||||
"selectAllOnPage": "ページ上のすべてを選択",
|
||||
"images": "画像",
|
||||
"assetsTab": "プロジェクトで使用するためにアップロードされたファイル。",
|
||||
"imagesTab": "Invoke内で作成および保存された画像。",
|
||||
"assets": "アセット"
|
||||
},
|
||||
"hotkeys": {
|
||||
"searchHotkeys": "ホットキーを検索",
|
||||
@@ -180,44 +205,121 @@
|
||||
},
|
||||
"canvas": {
|
||||
"redo": {
|
||||
"title": "やり直し"
|
||||
"title": "やり直し",
|
||||
"desc": "最後のキャンバス操作をやり直します。"
|
||||
},
|
||||
"transformSelected": {
|
||||
"title": "変形"
|
||||
"title": "変形",
|
||||
"desc": "選択したレイヤーを変形します。"
|
||||
},
|
||||
"undo": {
|
||||
"title": "取り消し"
|
||||
"title": "取り消し",
|
||||
"desc": "最後のキャンバス操作を取り消します。"
|
||||
},
|
||||
"selectEraserTool": {
|
||||
"title": "消しゴムツール"
|
||||
"title": "消しゴムツール",
|
||||
"desc": "消しゴムツールを選択します。"
|
||||
},
|
||||
"cancelTransform": {
|
||||
"title": "変形をキャンセル"
|
||||
"title": "変形をキャンセル",
|
||||
"desc": "保留中の変形をキャンセルします。"
|
||||
},
|
||||
"resetSelected": {
|
||||
"title": "レイヤーをリセット"
|
||||
"title": "レイヤーをリセット",
|
||||
"desc": "選択したレイヤーをリセットします。この操作はInpaint MaskおよびRegional Guidanceにのみ適用されます。"
|
||||
},
|
||||
"applyTransform": {
|
||||
"title": "変形を適用"
|
||||
"title": "変形を適用",
|
||||
"desc": "保留中の変形を選択したレイヤーに適用します。"
|
||||
},
|
||||
"selectColorPickerTool": {
|
||||
"title": "スポイトツール"
|
||||
"title": "スポイトツール",
|
||||
"desc": "スポイトツールを選択します。"
|
||||
},
|
||||
"fitBboxToCanvas": {
|
||||
"title": "バウンディングボックスをキャンバスにフィット"
|
||||
"title": "バウンディングボックスをキャンバスにフィット",
|
||||
"desc": "バウンディングボックスがキャンバスに収まるように表示を拡大、位置調整します。"
|
||||
},
|
||||
"selectBrushTool": {
|
||||
"title": "ブラシツール"
|
||||
"title": "ブラシツール",
|
||||
"desc": "ブラシツールを選択します。"
|
||||
},
|
||||
"selectMoveTool": {
|
||||
"title": "移動ツール"
|
||||
"title": "移動ツール",
|
||||
"desc": "移動ツールを選択します。"
|
||||
},
|
||||
"selectBboxTool": {
|
||||
"title": "バウンディングボックスツール"
|
||||
"title": "バウンディングボックスツール",
|
||||
"desc": "バウンディングボックスツールを選択します。"
|
||||
},
|
||||
"title": "キャンバス",
|
||||
"fitLayersToCanvas": {
|
||||
"title": "レイヤーをキャンバスにフィット"
|
||||
"title": "レイヤーをキャンバスにフィット",
|
||||
"desc": "すべての表示レイヤーがキャンバスに収まるように表示を拡大、位置調整します。"
|
||||
},
|
||||
"setZoomTo400Percent": {
|
||||
"desc": "キャンバスのズームを400%に設定します。",
|
||||
"title": "400%にズーム"
|
||||
},
|
||||
"setZoomTo800Percent": {
|
||||
"title": "800%にズーム",
|
||||
"desc": "キャンバスのズームを800%に設定します。"
|
||||
},
|
||||
"quickSwitch": {
|
||||
"title": "レイヤーのクイックスイッチ",
|
||||
"desc": "最後に選択した2つのレイヤー間を切り替えます。レイヤーがブックマークされている場合、常にそのレイヤーと最後に選択したブックマークされていないレイヤーの間を切り替えます。"
|
||||
},
|
||||
"nextEntity": {
|
||||
"title": "次のレイヤー",
|
||||
"desc": "リスト内の次のレイヤーを選択します。"
|
||||
},
|
||||
"filterSelected": {
|
||||
"title": "フィルター",
|
||||
"desc": "選択したレイヤーをフィルターします。RasterおよびControlレイヤーにのみ適用されます。"
|
||||
},
|
||||
"prevEntity": {
|
||||
"desc": "リスト内の前のレイヤーを選択します。",
|
||||
"title": "前のレイヤー"
|
||||
},
|
||||
"setFillToWhite": {
|
||||
"title": "ツール色を白に設定",
|
||||
"desc": "現在のツールの色を白色に設定します。"
|
||||
},
|
||||
"selectViewTool": {
|
||||
"title": "表示ツール",
|
||||
"desc": "表示ツールを選択します。"
|
||||
},
|
||||
"setZoomTo100Percent": {
|
||||
"title": "100%にズーム",
|
||||
"desc": "キャンバスのズームを100%に設定します。"
|
||||
},
|
||||
"deleteSelected": {
|
||||
"desc": "選択したレイヤーを削除します。",
|
||||
"title": "レイヤーを削除"
|
||||
},
|
||||
"cancelFilter": {
|
||||
"desc": "保留中のフィルターをキャンセルします。",
|
||||
"title": "フィルターをキャンセル"
|
||||
},
|
||||
"applyFilter": {
|
||||
"title": "フィルターを適用",
|
||||
"desc": "保留中のフィルターを選択したレイヤーに適用します。"
|
||||
},
|
||||
"setZoomTo200Percent": {
|
||||
"title": "200%にズーム",
|
||||
"desc": "キャンバスのズームを200%に設定します。"
|
||||
},
|
||||
"decrementToolWidth": {
|
||||
"title": "ツール幅を縮小する",
|
||||
"desc": "選択中のブラシまたは消しゴムツールの幅を減少させます。"
|
||||
},
|
||||
"incrementToolWidth": {
|
||||
"desc": "選択中のブラシまたは消しゴムツールの幅を増加させます。",
|
||||
"title": "ツール幅を増加する"
|
||||
},
|
||||
"selectRectTool": {
|
||||
"title": "矩形ツール",
|
||||
"desc": "矩形ツールを選択します。"
|
||||
}
|
||||
},
|
||||
"workflows": {
|
||||
@@ -226,6 +328,13 @@
|
||||
},
|
||||
"redo": {
|
||||
"title": "やり直し"
|
||||
},
|
||||
"title": "ワークフロー",
|
||||
"pasteSelection": {
|
||||
"title": "ペースト"
|
||||
},
|
||||
"copySelection": {
|
||||
"title": "コピー"
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
@@ -235,16 +344,62 @@
|
||||
},
|
||||
"title": "アプリケーション",
|
||||
"invoke": {
|
||||
"title": "Invoke"
|
||||
"title": "生成",
|
||||
"desc": "生成をキューに追加し、キューの末尾に加えます。"
|
||||
},
|
||||
"cancelQueueItem": {
|
||||
"title": "キャンセル"
|
||||
"title": "キャンセル",
|
||||
"desc": "現在処理中のキュー項目をキャンセルします。"
|
||||
},
|
||||
"clearQueue": {
|
||||
"title": "キューをクリア"
|
||||
"title": "キューをクリア",
|
||||
"desc": "すべてのキュー項目をキャンセルして消去します。"
|
||||
},
|
||||
"selectCanvasTab": {
|
||||
"desc": "キャンバスタブを選択します。",
|
||||
"title": "キャンバスタブを選択"
|
||||
},
|
||||
"selectUpscalingTab": {
|
||||
"desc": "アップスケーリングタブを選択します。",
|
||||
"title": "アップスケーリングタブを選択"
|
||||
},
|
||||
"toggleRightPanel": {
|
||||
"desc": "右パネルを表示または非表示。",
|
||||
"title": "右パネルをトグル"
|
||||
},
|
||||
"selectModelsTab": {
|
||||
"title": "モデルタブを選択",
|
||||
"desc": "モデルタブを選択します。"
|
||||
},
|
||||
"invokeFront": {
|
||||
"desc": "生成をキューに追加し、キューの先頭に加えます。",
|
||||
"title": "生成(先頭)"
|
||||
},
|
||||
"resetPanelLayout": {
|
||||
"title": "パネルレイアウトをリセット",
|
||||
"desc": "左パネルと右パネルをデフォルトのサイズとレイアウトにリセットします。"
|
||||
},
|
||||
"togglePanels": {
|
||||
"desc": "左パネルと右パネルを合わせて表示または非表示。",
|
||||
"title": "パネルをトグル"
|
||||
},
|
||||
"selectWorkflowsTab": {
|
||||
"desc": "ワークフロータブを選択します。",
|
||||
"title": "ワークフロータブを選択"
|
||||
},
|
||||
"selectQueueTab": {
|
||||
"title": "キュータブを選択",
|
||||
"desc": "キュータブを選択します。"
|
||||
},
|
||||
"focusPrompt": {
|
||||
"title": "プロンプトにフォーカス",
|
||||
"desc": "カーソルをポジティブプロンプト欄に移動します。"
|
||||
}
|
||||
},
|
||||
"hotkeys": "ホットキー"
|
||||
"hotkeys": "ホットキー",
|
||||
"gallery": {
|
||||
"title": "ギャラリー"
|
||||
}
|
||||
},
|
||||
"modelManager": {
|
||||
"modelManager": "モデルマネージャ",
|
||||
@@ -255,13 +410,13 @@
|
||||
"name": "名前",
|
||||
"description": "概要",
|
||||
"config": "コンフィグ",
|
||||
"repo_id": "Repo ID",
|
||||
"repo_id": "リポジトリID",
|
||||
"width": "幅",
|
||||
"height": "高さ",
|
||||
"addModel": "モデルを追加",
|
||||
"availableModels": "モデルを有効化",
|
||||
"search": "検索",
|
||||
"load": "Load",
|
||||
"load": "ロード",
|
||||
"active": "active",
|
||||
"selected": "選択済",
|
||||
"delete": "削除",
|
||||
@@ -281,7 +436,7 @@
|
||||
"modelConverted": "モデル変換が完了しました",
|
||||
"predictionType": "予測タイプ(SD 2.x モデルおよび一部のSD 1.x モデル用)",
|
||||
"selectModel": "モデルを選択",
|
||||
"advanced": "高度な設定",
|
||||
"advanced": "高度",
|
||||
"modelDeleted": "モデルが削除されました",
|
||||
"convertToDiffusersHelpText2": "このプロセスでは、モデルマネージャーのエントリーを同じモデルのディフューザーバージョンに置き換えます。",
|
||||
"modelUpdateFailed": "モデル更新が失敗しました",
|
||||
@@ -294,7 +449,20 @@
|
||||
"convertToDiffusersHelpText4": "これは一回限りのプロセスです。コンピュータの仕様によっては、約30秒から60秒かかる可能性があります。",
|
||||
"cancel": "キャンセル",
|
||||
"uploadImage": "画像をアップロード",
|
||||
"addModels": "モデルを追加"
|
||||
"addModels": "モデルを追加",
|
||||
"modelName": "モデル名",
|
||||
"source": "ソース",
|
||||
"path": "パス",
|
||||
"modelSettings": "モデル設定",
|
||||
"vae": "VAE",
|
||||
"huggingFace": "HuggingFace",
|
||||
"huggingFaceRepoID": "HuggingFace リポジトリID",
|
||||
"metadata": "メタデータ",
|
||||
"loraModels": "LoRA",
|
||||
"edit": "編集",
|
||||
"install": "インストール",
|
||||
"huggingFacePlaceholder": "owner/model-name",
|
||||
"variant": "Variant"
|
||||
},
|
||||
"parameters": {
|
||||
"images": "画像",
|
||||
@@ -305,7 +473,7 @@
|
||||
"shuffle": "シャッフル",
|
||||
"strength": "強度",
|
||||
"upscaling": "アップスケーリング",
|
||||
"scale": "Scale",
|
||||
"scale": "スケール",
|
||||
"scaleBeforeProcessing": "処理前のスケール",
|
||||
"scaledWidth": "幅のスケール",
|
||||
"scaledHeight": "高さのスケール",
|
||||
@@ -314,7 +482,7 @@
|
||||
"useSeed": "シード値を使用",
|
||||
"useAll": "すべてを使用",
|
||||
"info": "情報",
|
||||
"showOptionsPanel": "オプションパネルを表示",
|
||||
"showOptionsPanel": "サイドパネルを表示 (O or T)",
|
||||
"iterations": "生成回数",
|
||||
"general": "基本設定",
|
||||
"setToOptimalSize": "サイズをモデルに最適化",
|
||||
@@ -328,16 +496,29 @@
|
||||
"useSize": "サイズを使用",
|
||||
"postProcessing": "ポストプロセス (Shift + U)",
|
||||
"denoisingStrength": "ノイズ除去強度",
|
||||
"recallMetadata": "メタデータを再使用"
|
||||
"recallMetadata": "メタデータを再使用",
|
||||
"copyImage": "画像をコピー",
|
||||
"positivePromptPlaceholder": "ポジティブプロンプト",
|
||||
"negativePromptPlaceholder": "ネガティブプロンプト",
|
||||
"type": "タイプ",
|
||||
"cancel": {
|
||||
"cancel": "キャンセル"
|
||||
},
|
||||
"cfgScale": "CFGスケール",
|
||||
"tileSize": "タイルサイズ",
|
||||
"coherenceMode": "モード"
|
||||
},
|
||||
"settings": {
|
||||
"models": "モデル",
|
||||
"displayInProgress": "生成中の画像を表示する",
|
||||
"displayInProgress": "生成中の画像を表示",
|
||||
"confirmOnDelete": "削除時に確認",
|
||||
"resetWebUI": "WebUIをリセット",
|
||||
"resetWebUIDesc1": "WebUIのリセットは、画像と保存された設定のキャッシュをリセットするだけです。画像を削除するわけではありません。",
|
||||
"resetWebUIDesc2": "もしギャラリーに画像が表示されないなど、何か問題が発生した場合はGitHubにissueを提出する前にリセットを試してください。",
|
||||
"resetComplete": "WebUIはリセットされました。F5を押して再読み込みしてください。"
|
||||
"resetComplete": "WebUIはリセットされました。",
|
||||
"ui": "ユーザーインターフェイス",
|
||||
"beta": "ベータ",
|
||||
"developer": "開発者"
|
||||
},
|
||||
"toast": {
|
||||
"uploadFailed": "アップロード失敗",
|
||||
@@ -345,7 +526,8 @@
|
||||
"imageUploadFailed": "画像のアップロードに失敗しました",
|
||||
"uploadFailedInvalidUploadDesc": "画像はPNGかJPGである必要があります。",
|
||||
"sentToUpscale": "アップスケーラーに転送しました",
|
||||
"imageUploaded": "画像をアップロードしました"
|
||||
"imageUploaded": "画像をアップロードしました",
|
||||
"serverError": "サーバーエラー"
|
||||
},
|
||||
"accessibility": {
|
||||
"invokeProgressBar": "進捗バー",
|
||||
@@ -356,7 +538,7 @@
|
||||
"menu": "メニュー",
|
||||
"createIssue": "問題を報告",
|
||||
"resetUI": "$t(accessibility.reset) UI",
|
||||
"mode": "モード:",
|
||||
"mode": "モード",
|
||||
"about": "Invoke について",
|
||||
"submitSupportTicket": "サポート依頼を送信する",
|
||||
"uploadImages": "画像をアップロード",
|
||||
@@ -373,7 +555,20 @@
|
||||
"positivePrompt": "ポジティブプロンプト",
|
||||
"strength": "Image to Image 強度",
|
||||
"recallParameters": "パラメータを再使用",
|
||||
"recallParameter": "{{label}} を再使用"
|
||||
"recallParameter": "{{label}} を再使用",
|
||||
"imageDimensions": "画像サイズ",
|
||||
"imageDetails": "画像の詳細",
|
||||
"model": "モデル",
|
||||
"allPrompts": "すべてのプロンプト",
|
||||
"cfgScale": "CFGスケール",
|
||||
"createdBy": "作成:",
|
||||
"metadata": "メタデータ",
|
||||
"height": "高さ",
|
||||
"negativePrompt": "ネガティブプロンプト",
|
||||
"generationMode": "生成モード",
|
||||
"vae": "VAE",
|
||||
"cfgRescaleMultiplier": "$t(parameters.cfgRescaleMultiplier)",
|
||||
"canvasV2Metadata": "キャンバス"
|
||||
},
|
||||
"queue": {
|
||||
"queueEmpty": "キューが空です",
|
||||
@@ -405,7 +600,7 @@
|
||||
"batchQueuedDesc_other": "{{count}} セッションをキューの{{direction}}に追加しました",
|
||||
"graphQueued": "グラフをキューに追加しました",
|
||||
"batch": "バッチ",
|
||||
"clearQueueAlertDialog": "キューをクリアすると、処理中のアイテムは直ちにキャンセルされ、キューは完全にクリアされます。",
|
||||
"clearQueueAlertDialog": "キューをクリアすると、処理中の項目は直ちにキャンセルされ、キューは完全にクリアされます。保留中のフィルターもキャンセルされます。",
|
||||
"pending": "保留中",
|
||||
"resumeFailed": "処理の再開に問題があります",
|
||||
"clear": "クリア",
|
||||
@@ -423,7 +618,7 @@
|
||||
"enqueueing": "バッチをキューに追加",
|
||||
"cancelBatchFailed": "バッチのキャンセルに問題があります",
|
||||
"clearQueueAlertDialog2": "キューをクリアしてもよろしいですか?",
|
||||
"item": "アイテム",
|
||||
"item": "項目",
|
||||
"graphFailedToQueue": "グラフをキューに追加できませんでした",
|
||||
"batchFieldValues": "バッチの詳細",
|
||||
"openQueue": "キューを開く",
|
||||
@@ -439,7 +634,17 @@
|
||||
"upscaling": "アップスケール",
|
||||
"generation": "生成",
|
||||
"other": "その他",
|
||||
"gallery": "ギャラリー"
|
||||
"gallery": "ギャラリー",
|
||||
"cancelAllExceptCurrentQueueItemAlertDialog2": "すべての保留中のキュー項目をキャンセルしてもよいですか?",
|
||||
"cancelAllExceptCurrentTooltip": "現在の項目を除いてすべてキャンセル",
|
||||
"origin": "先頭",
|
||||
"destination": "宛先",
|
||||
"confirm": "確認",
|
||||
"retryItem": "項目をリトライ",
|
||||
"batchSize": "バッチサイズ",
|
||||
"retryFailed": "項目のリトライに問題があります",
|
||||
"cancelAllExceptCurrentQueueItemAlertDialog": "現在の項目を除くすべてのキュー項目をキャンセルすると、保留中の項目は停止しますが、進行中の項目は完了します。",
|
||||
"retrySucceeded": "項目がリトライされました"
|
||||
},
|
||||
"models": {
|
||||
"noMatchingModels": "一致するモデルがありません",
|
||||
@@ -448,13 +653,14 @@
|
||||
"noModelsAvailable": "使用可能なモデルがありません",
|
||||
"selectModel": "モデルを選択してください",
|
||||
"concepts": "コンセプト",
|
||||
"addLora": "LoRAを追加"
|
||||
"addLora": "LoRAを追加",
|
||||
"lora": "LoRA"
|
||||
},
|
||||
"nodes": {
|
||||
"addNode": "ノードを追加",
|
||||
"boolean": "ブーリアン",
|
||||
"addNodeToolTip": "ノードを追加 (Shift+A, Space)",
|
||||
"missingTemplate": "テンプレートが見つかりません",
|
||||
"missingTemplate": "Invalid node: タイプ {{type}} のノード {{node}} にテンプレートがありません(未インストール?)",
|
||||
"loadWorkflow": "ワークフローを読み込み",
|
||||
"hideLegendNodes": "フィールドタイプの凡例を非表示",
|
||||
"float": "浮動小数点",
|
||||
@@ -465,7 +671,7 @@
|
||||
"currentImageDescription": "ノードエディタ内の現在の画像を表示",
|
||||
"downloadWorkflow": "ワークフローのJSONをダウンロード",
|
||||
"fieldTypesMustMatch": "フィールドタイプが一致している必要があります",
|
||||
"edge": "輪郭",
|
||||
"edge": "エッジ",
|
||||
"animatedEdgesHelp": "選択したエッジおよび選択したノードに接続されたエッジをアニメーション化します",
|
||||
"cannotDuplicateConnection": "重複した接続は作れません",
|
||||
"noWorkflow": "ワークフローがありません",
|
||||
@@ -484,7 +690,20 @@
|
||||
"cannotConnectToSelf": "自身のノードには接続できません",
|
||||
"colorCodeEdges": "カラー-Code Edges",
|
||||
"loadingNodes": "ノードを読み込み中...",
|
||||
"scheduler": "スケジューラー"
|
||||
"scheduler": "スケジューラー",
|
||||
"version": "バージョン",
|
||||
"edit": "編集",
|
||||
"nodeVersion": "ノードバージョン",
|
||||
"workflowTags": "タグ",
|
||||
"string": "文字列",
|
||||
"workflowVersion": "バージョン",
|
||||
"workflowAuthor": "作者",
|
||||
"ipAdapter": "IP-Adapter",
|
||||
"notes": "ノート",
|
||||
"workflow": "ワークフロー",
|
||||
"workflowName": "名前",
|
||||
"workflowNotes": "ノート",
|
||||
"enum": "Enum"
|
||||
},
|
||||
"boards": {
|
||||
"autoAddBoard": "自動追加するボード",
|
||||
@@ -506,7 +725,7 @@
|
||||
"deleteBoard": "ボードの削除",
|
||||
"deleteBoardAndImages": "ボードと画像の削除",
|
||||
"deleteBoardOnly": "ボードのみ削除",
|
||||
"deletedBoardsCannotbeRestored": "削除されたボードは復元できません",
|
||||
"deletedBoardsCannotbeRestored": "削除されたボードは復元できません。\"ボードのみ削除\"を選択すると画像は未分類に移動されます。",
|
||||
"movingImagesToBoard_other": "{{count}} の画像をボードに移動:",
|
||||
"hideBoards": "ボードを隠す",
|
||||
"assetsWithCount_other": "{{count}} のアセット",
|
||||
@@ -518,7 +737,12 @@
|
||||
"archiveBoard": "ボードをアーカイブ",
|
||||
"archived": "アーカイブ完了",
|
||||
"unarchiveBoard": "アーカイブされていないボード",
|
||||
"imagesWithCount_other": "{{count}} の画像"
|
||||
"imagesWithCount_other": "{{count}} の画像",
|
||||
"updateBoardError": "ボード更新エラー",
|
||||
"selectedForAutoAdd": "自動追加に選択済み",
|
||||
"deletedPrivateBoardsCannotbeRestored": "削除されたボードは復元できません。\"ボードのみ削除\"を選択すると画像はその作成者のプライベートな未分類に移動されます。",
|
||||
"noBoards": "{{boardType}} ボードがありません",
|
||||
"viewBoards": "ボードを表示"
|
||||
},
|
||||
"invocationCache": {
|
||||
"invocationCache": "呼び出しキャッシュ",
|
||||
@@ -570,6 +794,57 @@
|
||||
},
|
||||
"paramAspect": {
|
||||
"heading": "縦横比"
|
||||
},
|
||||
"refinerSteps": {
|
||||
"heading": "ステップ"
|
||||
},
|
||||
"paramVAE": {
|
||||
"heading": "VAE"
|
||||
},
|
||||
"scale": {
|
||||
"heading": "スケール"
|
||||
},
|
||||
"refinerScheduler": {
|
||||
"heading": "スケジューラー"
|
||||
},
|
||||
"compositingCoherenceMode": {
|
||||
"heading": "モード"
|
||||
},
|
||||
"paramModel": {
|
||||
"heading": "モデル"
|
||||
},
|
||||
"paramHeight": {
|
||||
"heading": "高さ"
|
||||
},
|
||||
"paramSteps": {
|
||||
"heading": "ステップ"
|
||||
},
|
||||
"ipAdapterMethod": {
|
||||
"heading": "モード"
|
||||
},
|
||||
"paramSeed": {
|
||||
"heading": "シード"
|
||||
},
|
||||
"paramIterations": {
|
||||
"heading": "生成回数"
|
||||
},
|
||||
"controlNet": {
|
||||
"heading": "ControlNet"
|
||||
},
|
||||
"paramWidth": {
|
||||
"heading": "幅"
|
||||
},
|
||||
"lora": {
|
||||
"heading": "LoRA"
|
||||
},
|
||||
"loraWeight": {
|
||||
"heading": "重み"
|
||||
},
|
||||
"patchmatchDownScaleSize": {
|
||||
"heading": "Downscale"
|
||||
},
|
||||
"controlNetWeight": {
|
||||
"heading": "重み"
|
||||
}
|
||||
},
|
||||
"accordions": {
|
||||
@@ -579,7 +854,8 @@
|
||||
"coherenceTab": "コヒーレンスパス"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "高度な設定"
|
||||
"title": "高度",
|
||||
"options": "$t(accordions.advanced.title) オプション"
|
||||
},
|
||||
"control": {
|
||||
"title": "コントロール"
|
||||
@@ -608,7 +884,11 @@
|
||||
},
|
||||
"ui": {
|
||||
"tabs": {
|
||||
"queue": "キュー"
|
||||
"queue": "キュー",
|
||||
"canvas": "キャンバス",
|
||||
"workflows": "ワークフロー",
|
||||
"models": "モデル",
|
||||
"gallery": "ギャラリー"
|
||||
}
|
||||
},
|
||||
"controlLayers": {
|
||||
@@ -623,7 +903,8 @@
|
||||
"bboxGroup": "バウンディングボックスから作成",
|
||||
"cropCanvasToBbox": "キャンバスをバウンディングボックスでクロップ",
|
||||
"newGlobalReferenceImage": "新規全域参照画像",
|
||||
"newRegionalReferenceImage": "新規領域参照画像"
|
||||
"newRegionalReferenceImage": "新規領域参照画像",
|
||||
"canvasGroup": "キャンバス"
|
||||
},
|
||||
"regionalGuidance": "領域ガイダンス",
|
||||
"globalReferenceImage": "全域参照画像",
|
||||
@@ -644,7 +925,8 @@
|
||||
"brush": "ブラシ",
|
||||
"rectangle": "矩形",
|
||||
"move": "移動",
|
||||
"eraser": "消しゴム"
|
||||
"eraser": "消しゴム",
|
||||
"bbox": "Bbox"
|
||||
},
|
||||
"saveCanvasToGallery": "キャンバスをギャラリーに保存",
|
||||
"saveBboxToGallery": "バウンディングボックスをギャラリーへ保存",
|
||||
@@ -662,7 +944,27 @@
|
||||
"canvas": "キャンバス",
|
||||
"fitBboxToLayers": "バウンディングボックスをレイヤーにフィット",
|
||||
"removeBookmark": "ブックマークを外す",
|
||||
"savedToGalleryOk": "ギャラリーに保存しました"
|
||||
"savedToGalleryOk": "ギャラリーに保存しました",
|
||||
"controlMode": {
|
||||
"prompt": "プロンプト"
|
||||
},
|
||||
"prompt": "プロンプト",
|
||||
"settings": {
|
||||
"snapToGrid": {
|
||||
"off": "オフ",
|
||||
"on": "オン"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
"filter": "フィルター",
|
||||
"spandrel_filter": {
|
||||
"model": "モデル"
|
||||
},
|
||||
"apply": "適用",
|
||||
"reset": "リセット",
|
||||
"cancel": "キャンセル"
|
||||
},
|
||||
"weight": "重み"
|
||||
},
|
||||
"stylePresets": {
|
||||
"clearTemplateSelection": "選択したテンプレートをクリア",
|
||||
@@ -674,15 +976,54 @@
|
||||
"createPromptTemplate": "プロンプトテンプレートを作成",
|
||||
"promptTemplateCleared": "プロンプトテンプレートをクリアしました",
|
||||
"searchByName": "名前で検索",
|
||||
"toggleViewMode": "表示モードを切り替え"
|
||||
"toggleViewMode": "表示モードを切り替え",
|
||||
"negativePromptColumn": "'negative_prompt'",
|
||||
"preview": "プレビュー",
|
||||
"nameColumn": "'name'",
|
||||
"type": "タイプ",
|
||||
"private": "プライベート",
|
||||
"name": "名称"
|
||||
},
|
||||
"upscaling": {
|
||||
"upscaleModel": "アップスケールモデル",
|
||||
"postProcessingModel": "ポストプロセスモデル",
|
||||
"upscale": "アップスケール"
|
||||
"upscale": "アップスケール",
|
||||
"scale": "スケール"
|
||||
},
|
||||
"sdxl": {
|
||||
"denoisingStrength": "ノイズ除去強度",
|
||||
"scheduler": "スケジューラー"
|
||||
"scheduler": "スケジューラー",
|
||||
"loading": "ロード中...",
|
||||
"steps": "ステップ",
|
||||
"refiner": "Refiner"
|
||||
},
|
||||
"modelCache": {
|
||||
"clear": "モデルキャッシュを消去",
|
||||
"clearSucceeded": "モデルキャッシュを消去しました",
|
||||
"clearFailed": "モデルキャッシュの消去中に問題が発生"
|
||||
},
|
||||
"workflows": {
|
||||
"workflows": "ワークフロー",
|
||||
"ascending": "昇順",
|
||||
"name": "名前",
|
||||
"descending": "降順"
|
||||
},
|
||||
"system": {
|
||||
"logNamespaces": {
|
||||
"system": "システム",
|
||||
"gallery": "ギャラリー",
|
||||
"workflows": "ワークフロー",
|
||||
"models": "モデル",
|
||||
"canvas": "キャンバス",
|
||||
"metadata": "メタデータ",
|
||||
"queue": "キュー"
|
||||
},
|
||||
"logLevel": {
|
||||
"debug": "Debug",
|
||||
"info": "Info",
|
||||
"error": "Error",
|
||||
"fatal": "Fatal",
|
||||
"warn": "Warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
"compareImage": "So Sánh Ảnh",
|
||||
"compareHelp4": "Nhấn <Kbd>Z</Kbd> hoặc <Kbd>Esc</Kbd> để thoát.",
|
||||
"compareHelp3": "Nhấn <Kbd>C</Kbd> để đổi ảnh được so sánh.",
|
||||
"compareHelp1": "Giữ <Kbd>Alt</Kbd> khi bấm vào ảnh trong thư viện hoặc dùng phím mũi tên để đổi ảnh dùng cho so sánh.",
|
||||
"compareHelp1": "Giữ <Kbd>Alt</Kbd> khi bấm vào ảnh trong thư viện ảnh hoặc dùng phím mũi tên để đổi ảnh dùng cho so sánh.",
|
||||
"showArchivedBoards": "Hiển Thị Bảng Được Lưu Trữ",
|
||||
"drop": "Thả",
|
||||
"copy": "Sao Chép",
|
||||
@@ -76,11 +76,11 @@
|
||||
"deleteImagePermanent": "Ảnh đã xoá không thể phục hồi.",
|
||||
"exitSearch": "Thoát Tìm Kiếm Hình Ảnh",
|
||||
"exitBoardSearch": "Thoát Tìm Kiểm Bảng",
|
||||
"gallery": "Thư Viện",
|
||||
"gallery": "Thư Viện Ảnh",
|
||||
"galleryImageSize": "Kích Thước Ảnh",
|
||||
"downloadSelection": "Tải xuống Phần Được Lựa Chọn",
|
||||
"bulkDownloadRequested": "Chuẩn Bị Tải Xuống",
|
||||
"unableToLoad": "Không Thể Tải Thư viện",
|
||||
"unableToLoad": "Không Thể Tải Thư viện Ảnh",
|
||||
"newestFirst": "Mới Nhất Trước",
|
||||
"showStarredImagesFirst": "Hiển Thị Ảnh Gắn Sao Trước",
|
||||
"bulkDownloadRequestedDesc": "Yêu cầu tải xuống đang được chuẩn bị. Vui lòng chờ trong giây lát.",
|
||||
@@ -103,7 +103,7 @@
|
||||
"displaySearch": "Tìm Kiếm Hình Ảnh",
|
||||
"selectAnImageToCompare": "Chọn Ảnh Để So Sánh",
|
||||
"slider": "Thanh Trượt",
|
||||
"gallerySettings": "Cài Đặt Thư Viện",
|
||||
"gallerySettings": "Cài Đặt Thư Viện Ảnh",
|
||||
"image": "hình ảnh",
|
||||
"noImageSelected": "Không Có Ảnh Được Chọn",
|
||||
"noImagesInGallery": "Không Có Ảnh Để Hiển Thị",
|
||||
@@ -117,7 +117,9 @@
|
||||
"unstarImage": "Ngừng Gắn Sao Cho Ảnh",
|
||||
"compareHelp2": "Nhấn <Kbd>M</Kbd> để tuần hoàn trong chế độ so sánh.",
|
||||
"boardsSettings": "Thiết Lập Bảng",
|
||||
"imagesSettings": "Cài Đặt Thư Viện Ảnh"
|
||||
"imagesSettings": "Cài Đặt Ảnh Trong Thư Viện Ảnh",
|
||||
"assets": "Tài Nguyên",
|
||||
"images": "Hình Ảnh"
|
||||
},
|
||||
"common": {
|
||||
"ipAdapter": "IP Adapter",
|
||||
@@ -229,7 +231,11 @@
|
||||
"max": "Tối Đa",
|
||||
"resetToDefaults": "Đặt Lại Về Mặc Định",
|
||||
"seed": "Hạt Giống",
|
||||
"combinatorial": "Tổ Hợp"
|
||||
"combinatorial": "Tổ Hợp",
|
||||
"column": "Cột",
|
||||
"layout": "Bố Cục",
|
||||
"row": "Hàng",
|
||||
"board": "Bảng"
|
||||
},
|
||||
"prompt": {
|
||||
"addPromptTrigger": "Thêm Prompt Trigger",
|
||||
@@ -284,7 +290,7 @@
|
||||
"cancelBatch": "Huỷ Bỏ Lô",
|
||||
"status": "Trạng Thái",
|
||||
"pending": "Đang Chờ",
|
||||
"gallery": "Thư Viện",
|
||||
"gallery": "Thư Viện Ảnh",
|
||||
"front": "trước",
|
||||
"batch": "Lô",
|
||||
"origin": "Nguồn Gốc",
|
||||
@@ -303,7 +309,14 @@
|
||||
"completedIn": "Hoàn tất trong",
|
||||
"graphQueued": "Đồ Thị Đã Vào Hàng",
|
||||
"batchQueuedDesc_other": "Thêm {{count}} phiên vào {{direction}} của hàng",
|
||||
"batchSize": "Kích Thước Lô"
|
||||
"batchSize": "Kích Thước Lô",
|
||||
"cancelAllExceptCurrentQueueItemAlertDialog": "Huỷ tất cả mục đang xếp hàng ngoại trừ việc nó sẽ dừng các mục đang chờ nhưng cho phép các mục đang chạy được hoàn tất.",
|
||||
"cancelAllExceptCurrentQueueItemAlertDialog2": "Bạn có chắc muốn huỷ tất cả mục đang chờ?",
|
||||
"cancelAllExceptCurrentTooltip": "Huỷ Bỏ Tất Cả Ngoại Trừ Mục Hiện Tại",
|
||||
"confirm": "Đồng Ý",
|
||||
"retrySucceeded": "Mục Đã Thử Lại",
|
||||
"retryFailed": "Có Vấn Đề Khi Thử Lại Mục",
|
||||
"retryItem": "Thử Lại Mục"
|
||||
},
|
||||
"hotkeys": {
|
||||
"canvas": {
|
||||
@@ -509,16 +522,16 @@
|
||||
},
|
||||
"gallery": {
|
||||
"galleryNavRight": {
|
||||
"desc": "Sang phải theo mạng lưới thư viện, chọn hình ảnh đó. Nếu đến cuối hàng, qua hàng tiếp theo. Nếu đến hình ảnh cuối cùng, qua trang tiếp theo.",
|
||||
"desc": "Sang phải theo mạng lưới thư viện ảnh, chọn hình ảnh đó. Nếu đến cuối hàng, qua hàng tiếp theo. Nếu đến hình ảnh cuối cùng, qua trang tiếp theo.",
|
||||
"title": "Sang Phải"
|
||||
},
|
||||
"galleryNavDown": {
|
||||
"title": "Đi Xuống",
|
||||
"desc": "Đi xuống theo mạng lưới thư viện, chọn hình ảnh đó. Nếu xuống cuối cùng trang, sang trang tiếp theo."
|
||||
"desc": "Đi xuống theo mạng lưới thư viện ảnh, chọn hình ảnh đó. Nếu xuống cuối cùng trang, sang trang tiếp theo."
|
||||
},
|
||||
"galleryNavLeft": {
|
||||
"title": "Sang Trái",
|
||||
"desc": "Sang trái theo mạng lưới thư viện, chọn hình ảnh đó. Nếu đến đầu hàng, về lại hàng trước đó. Nếu đến hình ảnh đầu tiên, về lại trang trước đó."
|
||||
"desc": "Sang trái theo mạng lưới thư viện ảnh, chọn hình ảnh đó. Nếu đến đầu hàng, về lại hàng trước đó. Nếu đến hình ảnh đầu tiên, về lại trang trước đó."
|
||||
},
|
||||
"galleryNavUpAlt": {
|
||||
"title": "Đi Lên (So Sánh Ảnh)",
|
||||
@@ -530,7 +543,7 @@
|
||||
},
|
||||
"galleryNavUp": {
|
||||
"title": "Đi Lên",
|
||||
"desc": "Đi lên theo mạng lưới thư viện, chọn hình ảnh đó. Nếu lên trên cùng trang, về lại trang trước đó."
|
||||
"desc": "Đi lên theo mạng lưới thư viện ảnh, chọn hình ảnh đó. Nếu lên trên cùng trang, về lại trang trước đó."
|
||||
},
|
||||
"galleryNavRightAlt": {
|
||||
"title": "Sang Phải (So Sánh Ảnh)",
|
||||
@@ -540,7 +553,7 @@
|
||||
"title": "Chọn Tất Cả Trên Trang",
|
||||
"desc": "Chọn tất cả ảnh trên trang hiện tại."
|
||||
},
|
||||
"title": "Thư Viện",
|
||||
"title": "Thư Viện Ảnh",
|
||||
"galleryNavDownAlt": {
|
||||
"title": "Đi Xuống (So Sánh Ảnh)",
|
||||
"desc": "Giống với \"Đi Xuống\", nhưng là chọn ảnh được so sánh, mở chế độ so sánh nếu chưa được mở."
|
||||
@@ -862,7 +875,7 @@
|
||||
"removeLinearView": "Xoá Khỏi Chế Độ Xem Tuyến Tính",
|
||||
"unknownErrorValidatingWorkflow": "Lỗi không rõ khi xác thực workflow",
|
||||
"unableToLoadWorkflow": "Không Thể Tải Workflow",
|
||||
"workflowSettings": "Cài Đặt Trình Biên Tập Viên Workflow",
|
||||
"workflowSettings": "Cài Đặt Biên Tập Workflow",
|
||||
"workflowVersion": "Phiên Bản",
|
||||
"unableToGetWorkflowVersion": "Không thể tìm phiên bản của lược đồ workflow",
|
||||
"collection": "Đa tài nguyên",
|
||||
@@ -959,7 +972,7 @@
|
||||
"versionUnknown": " Phiên Bản Không Rõ",
|
||||
"workflowContact": "Thông Tin Liên Lạc",
|
||||
"workflowName": "Tên",
|
||||
"saveToGallery": "Lưu Vào Thư Viện",
|
||||
"saveToGallery": "Lưu Vào Thư Viện Ảnh",
|
||||
"connectionWouldCreateCycle": "Kết nối này sẽ tạo ra vòng lặp",
|
||||
"addNode": "Thêm Node",
|
||||
"unsupportedAnyOfLength": "quá nhiều dữ liệu hợp nhất: {{count}}",
|
||||
@@ -990,7 +1003,20 @@
|
||||
"generatorLoading": "đang tải",
|
||||
"generatorLoadFromFile": "Tải Từ Tệp",
|
||||
"dynamicPromptsRandom": "Dynamic Prompts (Ngẫu Nhiên)",
|
||||
"dynamicPromptsCombinatorial": "Dynamic Prompts (Tổ Hợp)"
|
||||
"dynamicPromptsCombinatorial": "Dynamic Prompts (Tổ Hợp)",
|
||||
"missingSourceOrTargetNode": "Thiếu nguồn hoặc node mục tiêu",
|
||||
"missingSourceOrTargetHandle": "Thiếu nguồn hoặc mục tiêu xử lý",
|
||||
"deletedMissingNodeFieldFormElement": "Xóa vùng nhập bị thiếu: vùng {{fieldName}} của node {{nodeId}}",
|
||||
"description": "Mô Tả",
|
||||
"loadWorkflowDesc": "Tải workflow?",
|
||||
"loadWorkflowDesc2": "Workflow hiện tại của bạn có những điều chỉnh chưa được lưu.",
|
||||
"loadingTemplates": "Đang Tải {{name}}",
|
||||
"nodeName": "Tên Node",
|
||||
"unableToUpdateNode": "Cập nhật node thất bại: node {{node}} thuộc dạng {{type}} (có thể cần xóa và tạo lại)",
|
||||
"downloadWorkflowError": "Lỗi tải xuống workflow",
|
||||
"generatorImagesFromBoard": "Ảnh Từ Bảng",
|
||||
"generatorImagesCategory": "Phân Loại",
|
||||
"generatorImages_other": "{{count}} ảnh"
|
||||
},
|
||||
"popovers": {
|
||||
"paramCFGRescaleMultiplier": {
|
||||
@@ -1475,7 +1501,8 @@
|
||||
"emptyBatches": "lô trống",
|
||||
"batchNodeNotConnected": "Node Hàng Loạt chưa được kết nối: {{label}}",
|
||||
"batchNodeEmptyCollection": "Một vài node hàng loạt có tài nguyên rỗng",
|
||||
"invalidBatchConfigurationCannotCalculate": "Thiết lập lô không hợp lệ; không thể tính toán"
|
||||
"collectionEmpty": "tài nguyên trống",
|
||||
"batchNodeCollectionSizeMismatchNoGroupId": "tài nguyên theo nhóm có kích thước sai lệch"
|
||||
},
|
||||
"cfgScale": "Thang CFG",
|
||||
"useSeed": "Dùng Hạt Giống",
|
||||
@@ -1577,14 +1604,14 @@
|
||||
"clearIntermediates": "Dọn Sạch Sản Phẩm Trung Gian",
|
||||
"clearIntermediatesDisabled": "Hàng đợi phải trống để dọn dẹp các sản phẩm trung gian",
|
||||
"clearIntermediatesDesc1": "Dọn dẹp các sản phẩm trung gian sẽ làm mới trạng thái của Canvas và ControlNet.",
|
||||
"clearIntermediatesDesc2": "Các sản phẩm ảnh trung gian là sản phẩm phụ trong quá trình tạo sinh, khác với ảnh trong thư viện. Xoá sản phẩm trung gian sẽ giúp làm trống ổ đĩa.",
|
||||
"clearIntermediatesDesc2": "Các sản phẩm ảnh trung gian là sản phẩm phụ trong quá trình tạo sinh, khác với ảnh trong thư viện ảnh. Xoá sản phẩm trung gian sẽ giúp làm trống ổ đĩa.",
|
||||
"resetWebUI": "Khởi Động Lại Giao Diện Web",
|
||||
"showProgressInViewer": "Hiển Thị Hình Ảnh Đang Xử Lý Trong Trình Xem",
|
||||
"ui": "Giao Diện Người Dùng",
|
||||
"clearIntermediatesDesc3": "Ảnh trong thư viện sẽ không bị xoá.",
|
||||
"clearIntermediatesDesc3": "Ảnh trong thư viện ảnh sẽ không bị xoá.",
|
||||
"informationalPopoversDisabled": "Hộp Thoại Hỗ Trợ Thông Tin Đã Tắt",
|
||||
"resetComplete": "Giao diện web đã được khởi động lại.",
|
||||
"resetWebUIDesc2": "Nếu ảnh không được xuất hiện trong thư viện hoặc điều gì đó không ổn đang diễn ra, hãy thử khởi động lại trước khi báo lỗi trên Github.",
|
||||
"resetWebUIDesc2": "Nếu ảnh không được xuất hiện trong thư viện ảnh hoặc điều gì đó không ổn đang diễn ra, hãy thử khởi động lại trước khi báo lỗi trên Github.",
|
||||
"displayInProgress": "Hiển Thị Hình Ảnh Đang Xử Lý",
|
||||
"intermediatesClearedFailed": "Có Vấn Đề Khi Dọn Sạch Sản Phẩm Trung Gian",
|
||||
"enableInvisibleWatermark": "Bật Chế Độ Ẩn Watermark",
|
||||
@@ -1612,7 +1639,7 @@
|
||||
"width": "Chiều Rộng",
|
||||
"negativePrompt": "Lệnh Tiêu Cực",
|
||||
"removeBookmark": "Bỏ Đánh Dấu",
|
||||
"saveBboxToGallery": "Lưu Hộp Giới Hạn Vào Thư Viện",
|
||||
"saveBboxToGallery": "Lưu Hộp Giới Hạn Vào Thư Viện Ảnh",
|
||||
"global": "Toàn Vùng",
|
||||
"pullBboxIntoReferenceImageError": "Có Vấn Đề Khi Chuyển Hộp Giới Hạn Thành Ảnh Mẫu",
|
||||
"clearHistory": "Xoá Lịch Sử",
|
||||
@@ -1620,12 +1647,12 @@
|
||||
"mergeVisibleOk": "Đã gộp layer",
|
||||
"saveLayerToAssets": "Lưu Layer Vào Khu Tài Nguyên",
|
||||
"canvas": "Canvas",
|
||||
"savedToGalleryOk": "Đã Lưu Vào Thư Viện",
|
||||
"savedToGalleryOk": "Đã Lưu Vào Thư Viện Ảnh",
|
||||
"addGlobalReferenceImage": "Thêm $t(controlLayers.globalReferenceImage)",
|
||||
"clipToBbox": "Chuyển Nét Thành Hộp Giới Hạn",
|
||||
"moveToFront": "Chuyển Lên Trước",
|
||||
"mergeVisible": "Gộp Layer Đang Hiển Thị",
|
||||
"savedToGalleryError": "Lỗi khi lưu vào thư viện",
|
||||
"savedToGalleryError": "Lỗi khi lưu vào thư viện ảnh",
|
||||
"moveToBack": "Chuyển Về Sau",
|
||||
"moveBackward": "Chuyển Xuống Cuối",
|
||||
"newGlobalReferenceImageError": "Có Vấn Đề Khi Tạo Ảnh Mẫu Toàn Vùng",
|
||||
@@ -1645,7 +1672,7 @@
|
||||
"regional": "Khu Vực",
|
||||
"regionIsEmpty": "Vùng được chọn trống",
|
||||
"bookmark": "Đánh Dấu Để Đổi Nhanh",
|
||||
"saveCanvasToGallery": "Lưu Canvas Vào Thư Viện",
|
||||
"saveCanvasToGallery": "Lưu Canvas Vào Thư Viện Ảnh",
|
||||
"cropLayerToBbox": "Xén Layer Vào Hộp Giới Hạn",
|
||||
"mergeDown": "Gộp Xuống",
|
||||
"mergeVisibleError": "Lỗi khi gộp layer",
|
||||
@@ -1713,11 +1740,11 @@
|
||||
"pullBboxIntoLayer": "Chuyển Hộp Giới Hạn Vào Layer",
|
||||
"addInpaintMask": "Thêm $t(controlLayers.inpaintMask)",
|
||||
"addRegionalGuidance": "Thêm $t(controlLayers.regionalGuidance)",
|
||||
"sendToGallery": "Chuyển Tới Thư Viện",
|
||||
"sendToGallery": "Đã Chuyển Tới Thư Viện Ảnh",
|
||||
"unlocked": "Mở Khoá",
|
||||
"addReferenceImage": "Thêm $t(controlLayers.referenceImage)",
|
||||
"sendingToCanvas": "Chuyển Ảnh Tạo Sinh Vào Canvas",
|
||||
"sendingToGallery": "Chuyển Ảnh Tạo Sinh Vào Thư Viện",
|
||||
"sendingToGallery": "Chuyển Ảnh Tạo Sinh Vào Thư Viện Ảnh",
|
||||
"viewProgressOnCanvas": "Xem quá trình xử lý và ảnh đầu ra trong <Btn>Canvas</Btn>.",
|
||||
"inpaintMask_withCount_other": "Lớp Phủ Inpaint",
|
||||
"regionalGuidance_withCount_other": "Chỉ Dẫn Khu Vực",
|
||||
@@ -1728,7 +1755,7 @@
|
||||
"copyRasterLayerTo": "Sao Chép $t(controlLayers.rasterLayer) Tới",
|
||||
"copyControlLayerTo": "Sao Chép $t(controlLayers.controlLayer) Tới",
|
||||
"newRegionalGuidance": "$t(controlLayers.regionalGuidance) Mới",
|
||||
"newGallerySessionDesc": "Nó sẽ dọn sạch canvas và các thiết lập trừ model được chọn. Các ảnh được tạo sinh sẽ được chuyển đến thư viện.",
|
||||
"newGallerySessionDesc": "Nó sẽ dọn sạch canvas và các thiết lập trừ model được chọn. Các ảnh được tạo sinh sẽ được chuyển đến thư viện ảnh.",
|
||||
"stagingOnCanvas": "Hiển thị hình ảnh lên",
|
||||
"pullBboxIntoReferenceImage": "Chuyển Hộp Giới Hạn Vào Ảnh Mẫu",
|
||||
"maskFill": "Lấp Đầy Lớp Phủ",
|
||||
@@ -1750,8 +1777,8 @@
|
||||
"deleteReferenceImage": "Xoá Ảnh Mẫu",
|
||||
"inpaintMasks_withCount_visible": "Lớp Phủ Inpaint ({{count}})",
|
||||
"disableTransparencyEffect": "Tắt Hiệu Ứng Trong Suốt",
|
||||
"newGallerySession": "Phiên Thư Viện Mới",
|
||||
"sendToGalleryDesc": "Bấm 'Kích Hoạt' sẽ tiến hành tạo sinh và lưu ảnh vào thư viện.",
|
||||
"newGallerySession": "Phiên Thư Viện Ảnh Mới",
|
||||
"sendToGalleryDesc": "Bấm 'Kích Hoạt' sẽ tiến hành tạo sinh và lưu ảnh vào thư viện ảnh.",
|
||||
"opacity": "Độ Mờ Đục",
|
||||
"rectangle": "Hình Chữ Nhật",
|
||||
"addNegativePrompt": "Thêm $t(controlLayers.negativePrompt)",
|
||||
@@ -1786,21 +1813,24 @@
|
||||
"process": "Xử Lý"
|
||||
},
|
||||
"canvasContextMenu": {
|
||||
"saveBboxToGallery": "Lưu Hộp Giới Hạn Vào Thư Viện",
|
||||
"saveBboxToGallery": "Lưu Hộp Giới Hạn Vào Thư Viện Ảnh",
|
||||
"newGlobalReferenceImage": "Ảnh Mẫu Toàn Vùng Mới",
|
||||
"cropCanvasToBbox": "Xén Canvas Vào Hộp Giới Hạn",
|
||||
"newRegionalGuidance": "Chỉ Dẫn Khu Vực Mới",
|
||||
"saveToGalleryGroup": "Lưu Vào Thư Viện",
|
||||
"saveToGalleryGroup": "Lưu Vào Thư Viện Ảnh",
|
||||
"newInpaintMask": "Lớp Phủ Inpaint Mới",
|
||||
"saveCanvasToGallery": "Lưu Canvas Vào Thư Viện",
|
||||
"saveCanvasToGallery": "Lưu Canvas Vào Thư Viện Ảnh",
|
||||
"newRegionalReferenceImage": "Ảnh Mẫu Khu Vực Mới",
|
||||
"newControlLayer": "Layer Điều Khiển Được Mới",
|
||||
"newRasterLayer": "Layer Dạng Raster Mới",
|
||||
"bboxGroup": "Được Tạo Từ Hộp Giới Hạn",
|
||||
"canvasGroup": "Canvas"
|
||||
"canvasGroup": "Canvas",
|
||||
"copyCanvasToClipboard": "Sao Chép Canvas Vào Clipboard",
|
||||
"copyToClipboard": "Sao Chép Vào Clipboard",
|
||||
"copyBboxToClipboard": "Sao Chép Hộp Giới Hạn Vào Clipboard"
|
||||
},
|
||||
"stagingArea": {
|
||||
"saveToGallery": "Lưu Vào Thư Viện",
|
||||
"saveToGallery": "Lưu Vào Thư Viện Ảnh",
|
||||
"accept": "Chấp Nhận",
|
||||
"discard": "Bỏ Đi",
|
||||
"previous": "Trước",
|
||||
@@ -1914,6 +1944,30 @@
|
||||
"gaussian_type": "Gaussian",
|
||||
"noise_color": "Màu Nhiễu",
|
||||
"size": "Cỡ Nhiễu"
|
||||
},
|
||||
"adjust_image": {
|
||||
"channel": "Kênh Màu",
|
||||
"cyan": "Lục Lam (Cmyk)",
|
||||
"value_setting": "Giá Trị",
|
||||
"scale_values": "Giá Trị Theo Tỉ Lệ",
|
||||
"red": "Đỏ (Rgba)",
|
||||
"green": "Lục (rGba)",
|
||||
"blue": "Lam (rgBa)",
|
||||
"alpha": "Độ Trong Suốt (rgbA)",
|
||||
"luminosity": "Độ Sáng (Lab)",
|
||||
"magenta": "Hồng Đỏ (cMyk)",
|
||||
"yellow": "Vàng (cmYk)",
|
||||
"description": "Điều chỉnh kênh màu được chọn của ảnh.",
|
||||
"black": "Đen (cmyK)",
|
||||
"cr": "Cr (ycC)",
|
||||
"label": "Điều Chỉnh Ảnh",
|
||||
"value": "Độ Sáng (hsV)",
|
||||
"saturation": "Độ Bão Hoà (hSv)",
|
||||
"hue": "Vùng Màu (Hsv)",
|
||||
"a": "A (lAb)",
|
||||
"b": "B (laB)",
|
||||
"y": "Y (Ycc)",
|
||||
"cb": "Cb (yCc)"
|
||||
}
|
||||
},
|
||||
"transform": {
|
||||
@@ -1992,6 +2046,20 @@
|
||||
"rgReferenceImagesNotSupported": "Ảnh Mẫu Khu Vực không được hỗ trợ cho model cơ sở được chọn",
|
||||
"rgAutoNegativeNotSupported": "Tự Động Đảo Chiều không được hỗ trợ cho model cơ sở được chọn",
|
||||
"rgNoRegion": "không có khu vực được vẽ"
|
||||
},
|
||||
"pasteTo": "Dán Vào",
|
||||
"pasteToAssets": "Tài Nguyên",
|
||||
"pasteToAssetsDesc": "Dán Vào Tài Nguyên",
|
||||
"pasteToBbox": "Hộp Giới Hạn",
|
||||
"pasteToBboxDesc": "Layer Mới (Trong Hộp Giới Hạn)",
|
||||
"pasteToCanvas": "Canvas",
|
||||
"pasteToCanvasDesc": "Layer Mới (Trong Canvas)",
|
||||
"pastedTo": "Dán Vào {{destination}}",
|
||||
"regionCopiedToClipboard": "Sao Chép {{region}} Vào Clipboard",
|
||||
"copyRegionError": "Lỗi khi sao chép {{region}}",
|
||||
"errors": {
|
||||
"unableToLoadImage": "Không Thể Tải Hình Ảnh",
|
||||
"unableToFindImage": "Không Thể Tìm Hình Ảnh"
|
||||
}
|
||||
},
|
||||
"stylePresets": {
|
||||
@@ -2044,7 +2112,7 @@
|
||||
"enableLogging": "Bật Chế Độ Ghi Log",
|
||||
"logNamespaces": {
|
||||
"models": "Models",
|
||||
"gallery": "Thư Viện",
|
||||
"gallery": "Thư Viện Ảnh",
|
||||
"config": "Cấu Hình",
|
||||
"queue": "Queue",
|
||||
"workflows": "Workflow",
|
||||
@@ -2099,7 +2167,7 @@
|
||||
"parameterSetDesc": "Gợi lại {{parameter}}",
|
||||
"loadedWithWarnings": "Đã Tải Workflow Với Cảnh Báo",
|
||||
"outOfMemoryErrorDesc": "Thiết lập tạo sinh hiện tại đã vượt mức cho phép của thiết bị. Hãy điều chỉnh thiết lập và thử lại.",
|
||||
"setNodeField": "Đặt làm vùng cho node",
|
||||
"setNodeField": "Đặt làm vùng node",
|
||||
"problemRetrievingWorkflow": "Có Vấn Đề Khi Lấy Lại Workflow",
|
||||
"somethingWentWrong": "Có Vấn Đề Phát Sinh",
|
||||
"problemDeletingWorkflow": "Có Vấn Đề Khi Xoá Workflow",
|
||||
@@ -2123,11 +2191,16 @@
|
||||
"problemDownloadingImage": "Không Thể Tải Xuống Ảnh",
|
||||
"problemCopyingLayer": "Không Thể Sao Chép Layer",
|
||||
"problemSavingLayer": "Không Thể Lưu Layer",
|
||||
"outOfMemoryErrorDescLocal": "Làm theo <LinkComponent>hướng dẫn VRAM Thấp</LinkComponent> của chúng tôi để hạn chế OOM (Tràn bộ nhớ)."
|
||||
"outOfMemoryErrorDescLocal": "Làm theo <LinkComponent>hướng dẫn VRAM Thấp</LinkComponent> của chúng tôi để hạn chế OOM (Tràn bộ nhớ).",
|
||||
"unableToCopy": "Không Thể Sao Chép",
|
||||
"unableToCopyDesc_theseSteps": "các bước sau",
|
||||
"unableToCopyDesc": "Trình duyệt của bạn không hỗ trợ tính năng clipboard. Người dùng Firefox có thể khắc phục theo ",
|
||||
"pasteSuccess": "Dán Vào {{destination}}",
|
||||
"pasteFailed": "Dán Thất Bại"
|
||||
},
|
||||
"ui": {
|
||||
"tabs": {
|
||||
"gallery": "Thư Viện",
|
||||
"gallery": "Thư Viện Ảnh",
|
||||
"models": "Models",
|
||||
"generation": "Generation (Máy Tạo Sinh)",
|
||||
"upscaling": "Upscale (Nâng Cấp Chất Lượng Hình Ảnh)",
|
||||
@@ -2159,7 +2232,7 @@
|
||||
"savingWorkflow": "Đang Lưu Workflow...",
|
||||
"ascending": "Tăng Dần",
|
||||
"loading": "Đang Tải Workflow",
|
||||
"chooseWorkflowFromLibrary": "Chọn Workflow Từ Túi Đồ",
|
||||
"chooseWorkflowFromLibrary": "Chọn Workflow Từ Thư Viện",
|
||||
"workflows": "Workflow",
|
||||
"copyShareLinkForWorkflow": "Sao Chép Liên Kết Chia Sẻ Cho Workflow",
|
||||
"openWorkflow": "Mở Workflow",
|
||||
@@ -2179,11 +2252,42 @@
|
||||
"convertGraph": "Chuyển Đổi Đồ Thị",
|
||||
"saveWorkflowToProject": "Lưu Workflow Vào Dự Án",
|
||||
"workflowName": "Tên Workflow",
|
||||
"workflowLibrary": "Túi Đồ",
|
||||
"workflowLibrary": "Thư Viện",
|
||||
"opened": "Ngày Mở",
|
||||
"deleteWorkflow": "Xoá Workflow",
|
||||
"workflowEditorMenu": "Menu Biên Tập Viên Workflow",
|
||||
"uploadAndSaveWorkflow": "Tải Lên Túi Đồ"
|
||||
"workflowEditorMenu": "Menu Biên Tập Workflow",
|
||||
"uploadAndSaveWorkflow": "Tải Lên Thư Viện",
|
||||
"openLibrary": "Mở Thư Viện",
|
||||
"builder": {
|
||||
"resetAllNodeFields": "Tải Lại Các Vùng Node",
|
||||
"builder": "Trình Tạo Vùng Nhập",
|
||||
"layout": "Bố Cục",
|
||||
"row": "Hàng",
|
||||
"zoomToNode": "Phóng To Vào Node",
|
||||
"addToForm": "Thêm Vào Vùng Nhập",
|
||||
"label": "Nhãn Tên",
|
||||
"showDescription": "Hiện Dòng Mô Tả",
|
||||
"component": "Thành Phần",
|
||||
"numberInput": "Nhập Số",
|
||||
"singleLine": "Một Dòng",
|
||||
"multiLine": "Nhiều Dòng",
|
||||
"slider": "Thanh Trượt",
|
||||
"both": "Cả Hai",
|
||||
"emptyRootPlaceholderViewMode": "Chọn Chỉnh Sửa để bắt đầu tạo nên một vùng nhập cho workflow này.",
|
||||
"emptyRootPlaceholderEditMode": "Kéo thành phần vùng nhập hoặc vùng node vào đây để bắt đầu.",
|
||||
"containerPlaceholder": "Hộp Chứa Trống",
|
||||
"headingPlaceholder": "Đầu Dòng Trống",
|
||||
"textPlaceholder": "Văn Bản Trống",
|
||||
"column": "Cột",
|
||||
"deleteAllElements": "Xóa Tất Cả Thành Phần",
|
||||
"nodeField": "Vùng Node",
|
||||
"nodeFieldTooltip": "Để thêm vùng node, bấm vào dấu cộng nhỏ trên vùng trong Trình Biên Tập Workflow, hoặc kéo vùng theo tên của nó vào vùng nhập.",
|
||||
"workflowBuilderAlphaWarning": "Trình tạo vùng nhập đang trong giai đoạn alpha. Nó có thể xuất hiện những thay đổi đột ngột trước khi chính thức được phát hành.",
|
||||
"container": "Hộp Chứa",
|
||||
"heading": "Đầu Dòng",
|
||||
"text": "Văn Bản",
|
||||
"divider": "Gạch Chia"
|
||||
}
|
||||
},
|
||||
"upscaling": {
|
||||
"missingUpscaleInitialImage": "Thiếu ảnh dùng để upscale",
|
||||
@@ -2206,9 +2310,9 @@
|
||||
"incompatibleBaseModelDesc": "Upscale chỉ hỗ trợ cho model phiên bản SD1.5 và SDXL. Đổi model chính để bật lại tính năng upscale."
|
||||
},
|
||||
"newUserExperience": {
|
||||
"toGetStartedLocal": "Để bắt đầu, hãy chắc chắn đã tải xuống hoặc thêm vào model cần để chạy Invoke. Sau đó, nhập lệnh vào hộp và nhấp chuột vào <StrongComponent>Kích Hoạt</StrongComponent> để tạo ra bức ảnh đầu tiên. Chọn một mẫu trình bày cho lệnh để cải thiện kết quả. Bạn có thể chọn để lưu ảnh trực tiếp vào <StrongComponent>Thư Viện</StrongComponent> hoặc chỉnh sửa chúng ở <StrongComponent>Canvas</StrongComponent>.",
|
||||
"toGetStartedLocal": "Để bắt đầu, hãy chắc chắn đã tải xuống hoặc thêm vào model cần để chạy Invoke. Sau đó, nhập lệnh vào hộp và nhấp chuột vào <StrongComponent>Kích Hoạt</StrongComponent> để tạo ra bức ảnh đầu tiên. Chọn một mẫu trình bày cho lệnh để cải thiện kết quả. Bạn có thể chọn để lưu ảnh trực tiếp vào <StrongComponent>Thư Viện Ảnh</StrongComponent> hoặc chỉnh sửa chúng ở <StrongComponent>Canvas</StrongComponent>.",
|
||||
"gettingStartedSeries": "Cần thêm hướng dẫn? Xem thử <LinkComponent>Bắt Đầu Làm Quen</LinkComponent> để biết thêm mẹo khai thác toàn bộ tiềm năng của Invoke Studio.",
|
||||
"toGetStarted": "Để bắt đầu, hãy nhập lệnh vào hộp và nhấp chuột vào <StrongComponent>Kích Hoạt</StrongComponent> để tạo ra bức ảnh đầu tiên. Chọn một mẫu trình bày cho lệnh để cải thiện kết quả. Bạn có thể chọn để lưu ảnh trực tiếp vào <StrongComponent>Thư Viện</StrongComponent> hoặc chỉnh sửa chúng ở <StrongComponent>Canvas</StrongComponent>.",
|
||||
"toGetStarted": "Để bắt đầu, hãy nhập lệnh vào hộp và nhấp chuột vào <StrongComponent>Kích Hoạt</StrongComponent> để tạo ra bức ảnh đầu tiên. Chọn một mẫu trình bày cho lệnh để cải thiện kết quả. Bạn có thể chọn để lưu ảnh trực tiếp vào <StrongComponent>Thư Viện Ảnh</StrongComponent> hoặc chỉnh sửa chúng ở <StrongComponent>Canvas</StrongComponent>.",
|
||||
"noModelsInstalled": "Dường như bạn chưa tải model nào cả! Bạn có thể <DownloadStarterModelsButton>tải xuống các model khởi đầu</DownloadStarterModelsButton> hoặc <ImportModelsButton>nhập vào thêm model</ImportModelsButton>.",
|
||||
"lowVRAMMode": "Cho hiệu suất tốt nhất, hãy làm theo <LinkComponent>hướng dẫn VRAM Thấp</LinkComponent> của chúng tôi."
|
||||
},
|
||||
@@ -2218,11 +2322,8 @@
|
||||
"watchRecentReleaseVideos": "Xem Video Phát Hành Mới Nhất",
|
||||
"watchUiUpdatesOverview": "Xem Tổng Quan Về Những Cập Nhật Cho Giao Diện Người Dùng",
|
||||
"items": [
|
||||
"Chế độ VRAM thấp",
|
||||
"Trình quản lý bộ nhớ động",
|
||||
"Tải model nhanh hơn",
|
||||
"Ít lỗi bộ nhớ hơn",
|
||||
"Mở rộng khả năng xử lý hàng loạt workflow"
|
||||
"Trình Biên Tập Workflow: trình tạo vùng nhập dưới dạng kéo thả nhằm tạo dựng workflow dễ dàng hơn.",
|
||||
"Các nâng cấp khác: Xếp hàng tạo sinh theo nhóm nhanh hơn, upscale tốt hơn, trình chọn màu được cải thiện, và node chứa metadata."
|
||||
]
|
||||
},
|
||||
"upsell": {
|
||||
@@ -2254,8 +2355,8 @@
|
||||
"title": "Upscale (Nâng Cấp Chất Lượng Hình Ảnh)"
|
||||
},
|
||||
"howDoIGenerateAndSaveToTheGallery": {
|
||||
"title": "Làm Sao Để Tôi Tạo Sinh Và Lưu Vào Thư Viện?",
|
||||
"description": "Các bước để tạo sinh và lưu ảnh vào thư viện."
|
||||
"title": "Làm Sao Để Tôi Tạo Sinh Và Lưu Vào Thư Viện Ảnh?",
|
||||
"description": "Các bước để tạo sinh và lưu ảnh vào thư viện ảnh."
|
||||
},
|
||||
"howDoIEditOnTheCanvas": {
|
||||
"description": "Hướng dẫn chỉnh sửa ảnh trực tiếp trên canvas.",
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Box, useGlobalModifiersInit } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { GlobalImageHotkeys } from 'app/components/GlobalImageHotkeys';
|
||||
import type { StudioInitAction } from 'app/hooks/useStudioInitAction';
|
||||
import { useStudioInitAction } from 'app/hooks/useStudioInitAction';
|
||||
import { $didStudioInit, useStudioInitAction } from 'app/hooks/useStudioInitAction';
|
||||
import { useSyncQueueStatus } from 'app/hooks/useSyncQueueStatus';
|
||||
import { useLogger } from 'app/logging/useLogger';
|
||||
import { useSyncLoggingConfig } from 'app/logging/useSyncLoggingConfig';
|
||||
import { appStarted } from 'app/store/middleware/listenerMiddleware/listeners/appStarted';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import type { PartialAppConfig } from 'app/types/invokeai';
|
||||
import Loading from 'common/components/Loading/Loading';
|
||||
import { useFocusRegionWatcher } from 'common/hooks/focus';
|
||||
import { useClearStorage } from 'common/hooks/useClearStorage';
|
||||
import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys';
|
||||
@@ -27,6 +29,7 @@ import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterM
|
||||
import { ShareWorkflowModal } from 'features/nodes/components/sidePanel/WorkflowListMenu/ShareWorkflowModal';
|
||||
import { CancelAllExceptCurrentQueueItemConfirmationAlertDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog';
|
||||
import { ClearQueueConfirmationsAlertDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
|
||||
import { useReadinessWatcher } from 'features/queue/store/readiness';
|
||||
import { DeleteStylePresetDialog } from 'features/stylePresets/components/DeleteStylePresetDialog';
|
||||
import { StylePresetModal } from 'features/stylePresets/components/StylePresetForm/StylePresetModal';
|
||||
import RefreshAfterResetModal from 'features/system/components/SettingsModal/RefreshAfterResetModal';
|
||||
@@ -35,6 +38,7 @@ import { configChanged } from 'features/system/store/configSlice';
|
||||
import { selectLanguage } from 'features/system/store/systemSelectors';
|
||||
import { AppContent } from 'features/ui/components/AppContent';
|
||||
import { DeleteWorkflowDialog } from 'features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog';
|
||||
import { LoadWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
|
||||
import { NewWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog';
|
||||
import i18n from 'i18n';
|
||||
import { size } from 'lodash-es';
|
||||
@@ -53,49 +57,22 @@ interface Props {
|
||||
}
|
||||
|
||||
const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
|
||||
const language = useAppSelector(selectLanguage);
|
||||
const logger = useLogger('system');
|
||||
const dispatch = useAppDispatch();
|
||||
const didStudioInit = useStore($didStudioInit);
|
||||
const clearStorage = useClearStorage();
|
||||
|
||||
// singleton!
|
||||
useSocketIO();
|
||||
useGlobalModifiersInit();
|
||||
useGlobalHotkeys();
|
||||
useGetOpenAPISchemaQuery();
|
||||
useSyncLoggingConfig();
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
clearStorage();
|
||||
location.reload();
|
||||
return false;
|
||||
}, [clearStorage]);
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(language);
|
||||
}, [language]);
|
||||
|
||||
useEffect(() => {
|
||||
if (size(config)) {
|
||||
logger.info({ config }, 'Received config');
|
||||
dispatch(configChanged(config));
|
||||
}
|
||||
}, [dispatch, config, logger]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(appStarted());
|
||||
}, [dispatch]);
|
||||
|
||||
useStudioInitAction(studioInitAction);
|
||||
useStarterModelsToast();
|
||||
useSyncQueueStatus();
|
||||
useFocusRegionWatcher();
|
||||
|
||||
return (
|
||||
<ErrorBoundary onReset={handleReset} FallbackComponent={AppErrorBoundaryFallback}>
|
||||
<Box id="invoke-app-wrapper" w="100dvw" h="100dvh" position="relative" overflow="hidden">
|
||||
<AppContent />
|
||||
{!didStudioInit && <Loading />}
|
||||
</Box>
|
||||
<HookIsolator config={config} studioInitAction={studioInitAction} />
|
||||
<DeleteImageModal />
|
||||
<ChangeBoardModal />
|
||||
<DynamicPromptsModal />
|
||||
@@ -103,6 +80,7 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
|
||||
<CancelAllExceptCurrentQueueItemConfirmationAlertDialog />
|
||||
<ClearQueueConfirmationsAlertDialog />
|
||||
<NewWorkflowConfirmationAlertDialog />
|
||||
<LoadWorkflowConfirmationAlertDialog />
|
||||
<DeleteStylePresetDialog />
|
||||
<DeleteWorkflowDialog />
|
||||
<ShareWorkflowModal />
|
||||
@@ -122,3 +100,43 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
|
||||
};
|
||||
|
||||
export default memo(App);
|
||||
|
||||
// Running these hooks in a separate component ensures we do not inadvertently rerender the entire app when they change.
|
||||
const HookIsolator = memo(
|
||||
({ config, studioInitAction }: { config: PartialAppConfig; studioInitAction?: StudioInitAction }) => {
|
||||
const language = useAppSelector(selectLanguage);
|
||||
const logger = useLogger('system');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// singleton!
|
||||
useReadinessWatcher();
|
||||
useSocketIO();
|
||||
useGlobalModifiersInit();
|
||||
useGlobalHotkeys();
|
||||
useGetOpenAPISchemaQuery();
|
||||
useSyncLoggingConfig();
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(language);
|
||||
}, [language]);
|
||||
|
||||
useEffect(() => {
|
||||
if (size(config)) {
|
||||
logger.info({ config }, 'Received config');
|
||||
dispatch(configChanged(config));
|
||||
}
|
||||
}, [dispatch, config, logger]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(appStarted());
|
||||
}, [dispatch]);
|
||||
|
||||
useStudioInitAction(studioInitAction);
|
||||
useStarterModelsToast();
|
||||
useSyncQueueStatus();
|
||||
useFocusRegionWatcher();
|
||||
|
||||
return null;
|
||||
}
|
||||
);
|
||||
HookIsolator.displayName = 'HookIsolator';
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'i18n';
|
||||
|
||||
import type { Middleware } from '@reduxjs/toolkit';
|
||||
import type { StudioInitAction } from 'app/hooks/useStudioInitAction';
|
||||
import { $didStudioInit } from 'app/hooks/useStudioInitAction';
|
||||
import type { LoggingOverrides } from 'app/logging/logger';
|
||||
import { $loggingOverrides, configureLogging } from 'app/logging/logger';
|
||||
import { $authToken } from 'app/store/nanostores/authToken';
|
||||
@@ -87,6 +88,12 @@ const InvokeAIUI = ({
|
||||
);
|
||||
}, [loggingOverrides]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (studioInitAction) {
|
||||
$didStudioInit.set(false);
|
||||
}
|
||||
}, [studioInitAction]);
|
||||
|
||||
useEffect(() => {
|
||||
// configure API client token
|
||||
if (token) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import '@fontsource-variable/inter';
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
import '@xyflow/react/dist/base.css';
|
||||
|
||||
import { ChakraProvider, DarkMode, extendTheme, theme as _theme, TOAST_OPTIONS } from '@invoke-ai/ui-library';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
@@ -16,7 +16,8 @@ import { $isStylePresetsMenuOpen, activeStylePresetIdChanged } from 'features/st
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { activeTabCanvasRightPanelChanged, setActiveTab } from 'features/ui/store/uiSlice';
|
||||
import { useGetAndLoadLibraryWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { atom } from 'nanostores';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getImageDTO, getImageMetadata } from 'services/api/endpoints/images';
|
||||
import { getStylePreset } from 'services/api/endpoints/stylePresets';
|
||||
@@ -32,6 +33,9 @@ type StudioDestinationAction = _StudioInitAction<
|
||||
{ destination: 'generation' | 'canvas' | 'workflows' | 'upscaling' | 'viewAllWorkflows' | 'viewAllStylePresets' }
|
||||
>;
|
||||
|
||||
// Use global state to show loader until we are ready to render the studio.
|
||||
export const $didStudioInit = atom(false);
|
||||
|
||||
export type StudioInitAction =
|
||||
| LoadWorkflowAction
|
||||
| SelectStylePresetAction
|
||||
@@ -51,8 +55,6 @@ export type StudioInitAction =
|
||||
export const useStudioInitAction = (action?: StudioInitAction) => {
|
||||
useAssertSingleton('useStudioInitAction');
|
||||
const { t } = useTranslation();
|
||||
// Use a ref to ensure that we only perform the action once
|
||||
const didInit = useRef(false);
|
||||
const didParseOpenAPISchema = useStore($hasTemplates);
|
||||
const store = useAppStore();
|
||||
const { getAndLoadWorkflow } = useGetAndLoadLibraryWorkflow();
|
||||
@@ -102,16 +104,16 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
|
||||
}
|
||||
const metadata = getImageMetadataResult.value;
|
||||
// This shows a toast
|
||||
parseAndRecallAllMetadata(metadata, true);
|
||||
await parseAndRecallAllMetadata(metadata, true);
|
||||
store.dispatch(setActiveTab('canvas'));
|
||||
},
|
||||
[store, t]
|
||||
);
|
||||
|
||||
const handleLoadWorkflow = useCallback(
|
||||
(workflowId: string) => {
|
||||
async (workflowId: string) => {
|
||||
// This shows a toast
|
||||
getAndLoadWorkflow(workflowId);
|
||||
await getAndLoadWorkflow(workflowId);
|
||||
store.dispatch(setActiveTab('workflows'));
|
||||
},
|
||||
[getAndLoadWorkflow, store]
|
||||
@@ -176,36 +178,48 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
|
||||
[store]
|
||||
);
|
||||
|
||||
const handleStudioInitAction = useCallback(
|
||||
async (action: StudioInitAction) => {
|
||||
// This cannot be in the useEffect below because we need to await some of the actions before setting didStudioInit.
|
||||
switch (action.type) {
|
||||
case 'loadWorkflow':
|
||||
await handleLoadWorkflow(action.data.workflowId);
|
||||
break;
|
||||
case 'selectStylePreset':
|
||||
await handleSelectStylePreset(action.data.stylePresetId);
|
||||
break;
|
||||
|
||||
case 'sendToCanvas':
|
||||
await handleSendToCanvas(action.data.imageName);
|
||||
break;
|
||||
|
||||
case 'useAllParameters':
|
||||
await handleUseAllMetadata(action.data.imageName);
|
||||
break;
|
||||
|
||||
case 'goToDestination':
|
||||
handleGoToDestination(action.data.destination);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
$didStudioInit.set(true);
|
||||
},
|
||||
[handleGoToDestination, handleLoadWorkflow, handleSelectStylePreset, handleSendToCanvas, handleUseAllMetadata]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (didInit.current || !action || !didParseOpenAPISchema) {
|
||||
if ($didStudioInit.get() || !didParseOpenAPISchema) {
|
||||
return;
|
||||
}
|
||||
|
||||
didInit.current = true;
|
||||
|
||||
switch (action.type) {
|
||||
case 'loadWorkflow':
|
||||
handleLoadWorkflow(action.data.workflowId);
|
||||
break;
|
||||
case 'selectStylePreset':
|
||||
handleSelectStylePreset(action.data.stylePresetId);
|
||||
break;
|
||||
|
||||
case 'sendToCanvas':
|
||||
handleSendToCanvas(action.data.imageName);
|
||||
break;
|
||||
|
||||
case 'useAllParameters':
|
||||
handleUseAllMetadata(action.data.imageName);
|
||||
break;
|
||||
|
||||
case 'goToDestination':
|
||||
handleGoToDestination(action.data.destination);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
if (!action) {
|
||||
$didStudioInit.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
handleStudioInitAction(action);
|
||||
}, [
|
||||
handleSendToCanvas,
|
||||
handleUseAllMetadata,
|
||||
@@ -214,5 +228,6 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
|
||||
handleGoToDestination,
|
||||
handleLoadWorkflow,
|
||||
didParseOpenAPISchema,
|
||||
handleStudioInitAction,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { createDraftSafeSelectorCreator, createSelectorCreator, lruMemoize } from '@reduxjs/toolkit';
|
||||
import type { GetSelectorsOptions } from '@reduxjs/toolkit/dist/entities/state_selectors';
|
||||
import type { RootState } from 'app/store/store';
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
/**
|
||||
@@ -14,11 +12,9 @@ export const createMemoizedSelector = createSelectorCreator({
|
||||
argsMemoize: lruMemoize,
|
||||
});
|
||||
|
||||
export const getSelectorsOptions: GetSelectorsOptions = {
|
||||
export const getSelectorsOptions = {
|
||||
createSelector: createDraftSafeSelectorCreator({
|
||||
memoize: lruMemoize,
|
||||
argsMemoize: lruMemoize,
|
||||
}),
|
||||
};
|
||||
|
||||
export const createMemoizedAppSelector = createMemoizedSelector.withTypes<RootState>();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { enqueueRequested } from 'app/store/actions';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import { extractMessageFromAssertionError } from 'common/util/extractMessageFromAssertionError';
|
||||
import { withResult, withResultAsync } from 'common/util/result';
|
||||
import { parseify } from 'common/util/serialize';
|
||||
import { $canvasManager } from 'features/controlLayers/store/ephemeral';
|
||||
import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig';
|
||||
import { buildFLUXGraph } from 'features/nodes/util/graph/generation/buildFLUXGraph';
|
||||
@@ -13,7 +14,6 @@ import { toast } from 'features/toast/toast';
|
||||
import { serializeError } from 'serialize-error';
|
||||
import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endpoints/queue';
|
||||
import { assert, AssertionError } from 'tsafe';
|
||||
import type { JsonObject } from 'type-fest';
|
||||
|
||||
const log = logger('generation');
|
||||
|
||||
@@ -80,16 +80,15 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening)
|
||||
const req = dispatch(
|
||||
queueApi.endpoints.enqueueBatch.initiate(prepareBatchResult.value, enqueueMutationFixedCacheKeyOptions)
|
||||
);
|
||||
req.reset();
|
||||
|
||||
const enqueueResult = await withResultAsync(() => req.unwrap());
|
||||
|
||||
if (enqueueResult.isErr()) {
|
||||
log.error({ error: serializeError(enqueueResult.error) }, 'Failed to enqueue batch');
|
||||
return;
|
||||
try {
|
||||
await req.unwrap();
|
||||
log.debug(parseify({ batchConfig: prepareBatchResult.value }), 'Enqueued batch');
|
||||
} catch (error) {
|
||||
log.error({ error: serializeError(error) }, 'Failed to enqueue batch');
|
||||
} finally {
|
||||
req.reset();
|
||||
}
|
||||
|
||||
log.debug({ batchConfig: prepareBatchResult.value } as JsonObject, 'Enqueued batch');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
import { logger } from 'app/logging/logger';
|
||||
import { enqueueRequested } from 'app/store/actions';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import { parseify } from 'common/util/serialize';
|
||||
import { $templates } from 'features/nodes/store/nodesSlice';
|
||||
import { selectNodesSlice } from 'features/nodes/store/selectors';
|
||||
import { isBatchNode, isInvocationNode } from 'features/nodes/types/invocation';
|
||||
import { buildNodesGraph } from 'features/nodes/util/graph/buildNodesGraph';
|
||||
import { resolveBatchValue } from 'features/nodes/util/node/resolveBatchValue';
|
||||
import { buildWorkflowWithValidation } from 'features/nodes/util/workflow/buildWorkflow';
|
||||
import { resolveBatchValue } from 'features/queue/store/readiness';
|
||||
import { groupBy } from 'lodash-es';
|
||||
import { serializeError } from 'serialize-error';
|
||||
import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endpoints/queue';
|
||||
import type { Batch, BatchConfig } from 'services/api/types';
|
||||
|
||||
const log = logger('generation');
|
||||
|
||||
export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) => {
|
||||
startAppListening({
|
||||
predicate: (action): action is ReturnType<typeof enqueueRequested> =>
|
||||
enqueueRequested.match(action) && action.payload.tabName === 'workflows',
|
||||
effect: async (action, { getState, dispatch }) => {
|
||||
const state = getState();
|
||||
const nodes = selectNodesSlice(state);
|
||||
const nodesState = selectNodesSlice(state);
|
||||
const workflow = state.workflow;
|
||||
const graph = buildNodesGraph(nodes);
|
||||
const templates = $templates.get();
|
||||
const graph = buildNodesGraph(state, templates);
|
||||
const builtWorkflow = buildWorkflowWithValidation({
|
||||
nodes: nodes.nodes,
|
||||
edges: nodes.edges,
|
||||
nodes: nodesState.nodes,
|
||||
edges: nodesState.edges,
|
||||
workflow,
|
||||
});
|
||||
|
||||
@@ -31,7 +38,7 @@ export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) =
|
||||
|
||||
const data: Batch['data'] = [];
|
||||
|
||||
const invocationNodes = nodes.nodes.filter(isInvocationNode);
|
||||
const invocationNodes = nodesState.nodes.filter(isInvocationNode);
|
||||
const batchNodes = invocationNodes.filter(isBatchNode);
|
||||
|
||||
// Handle zipping batch nodes. First group the batch nodes by their batch_group_id
|
||||
@@ -42,9 +49,11 @@ export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) =
|
||||
const zippedBatchDataCollectionItems: NonNullable<Batch['data']>[number] = [];
|
||||
|
||||
for (const node of batchNodes) {
|
||||
const value = resolveBatchValue(node, invocationNodes, nodes.edges);
|
||||
const value = await resolveBatchValue({ nodesState, node, dispatch });
|
||||
const sourceHandle = node.data.type === 'image_batch' ? 'image' : 'value';
|
||||
const edgesFromBatch = nodes.edges.filter((e) => e.source === node.id && e.sourceHandle === sourceHandle);
|
||||
const edgesFromBatch = nodesState.edges.filter(
|
||||
(e) => e.source === node.id && e.sourceHandle === sourceHandle
|
||||
);
|
||||
if (batchGroupId !== 'None') {
|
||||
// If this batch node has a batch_group_id, we will zip the data collection items
|
||||
for (const edge of edgesFromBatch) {
|
||||
@@ -97,6 +106,9 @@ export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) =
|
||||
const req = dispatch(queueApi.endpoints.enqueueBatch.initiate(batchConfig, enqueueMutationFixedCacheKeyOptions));
|
||||
try {
|
||||
await req.unwrap();
|
||||
log.debug(parseify({ batchConfig }), 'Enqueued batch');
|
||||
} catch (error) {
|
||||
log.error({ error: serializeError(error) }, 'Failed to enqueue batch');
|
||||
} finally {
|
||||
req.reset();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { logger } from 'app/logging/logger';
|
||||
import { enqueueRequested } from 'app/store/actions';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import { parseify } from 'common/util/serialize';
|
||||
import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig';
|
||||
import { buildMultidiffusionUpscaleGraph } from 'features/nodes/util/graph/buildMultidiffusionUpscaleGraph';
|
||||
import { serializeError } from 'serialize-error';
|
||||
import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endpoints/queue';
|
||||
|
||||
const log = logger('generation');
|
||||
|
||||
export const addEnqueueRequestedUpscale = (startAppListening: AppStartListening) => {
|
||||
startAppListening({
|
||||
predicate: (action): action is ReturnType<typeof enqueueRequested> =>
|
||||
@@ -19,6 +24,9 @@ export const addEnqueueRequestedUpscale = (startAppListening: AppStartListening)
|
||||
const req = dispatch(queueApi.endpoints.enqueueBatch.initiate(batchConfig, enqueueMutationFixedCacheKeyOptions));
|
||||
try {
|
||||
await req.unwrap();
|
||||
log.debug(parseify({ batchConfig }), 'Enqueued batch');
|
||||
} catch (error) {
|
||||
log.error({ error: serializeError(error) }, 'Failed to enqueue batch');
|
||||
} finally {
|
||||
req.reset();
|
||||
}
|
||||
|
||||
@@ -8,12 +8,13 @@ import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions'
|
||||
import { isModalOpenChanged } from 'features/deleteImageModal/store/slice';
|
||||
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||
import { imageSelected } from 'features/gallery/store/gallerySlice';
|
||||
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
|
||||
import { isImageFieldInputInstance } from 'features/nodes/types/field';
|
||||
import { fieldImageCollectionValueChanged, fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
|
||||
import { isImageFieldCollectionInputInstance, isImageFieldInputInstance } from 'features/nodes/types/field';
|
||||
import { isInvocationNode } from 'features/nodes/types/invocation';
|
||||
import { forEach, intersectionBy } from 'lodash-es';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
import type { Param0 } from 'tsafe';
|
||||
|
||||
const log = logger('gallery');
|
||||
|
||||
@@ -21,6 +22,7 @@ const log = logger('gallery');
|
||||
|
||||
// Some utils to delete images from different parts of the app
|
||||
const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
|
||||
const actions: Param0<typeof dispatch>[] = [];
|
||||
state.nodes.present.nodes.forEach((node) => {
|
||||
if (!isInvocationNode(node)) {
|
||||
return;
|
||||
@@ -28,16 +30,28 @@ const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: Im
|
||||
|
||||
forEach(node.data.inputs, (input) => {
|
||||
if (isImageFieldInputInstance(input) && input.value?.image_name === imageDTO.image_name) {
|
||||
dispatch(
|
||||
actions.push(
|
||||
fieldImageValueChanged({
|
||||
nodeId: node.data.id,
|
||||
fieldName: input.name,
|
||||
value: undefined,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (isImageFieldCollectionInputInstance(input)) {
|
||||
actions.push(
|
||||
fieldImageCollectionValueChanged({
|
||||
nodeId: node.data.id,
|
||||
fieldName: input.name,
|
||||
value: input.value?.filter((value) => value?.image_name !== imageDTO.image_name),
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
actions.forEach(dispatch);
|
||||
};
|
||||
|
||||
const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { logger } from 'app/logging/logger';
|
||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||
import { $nodeExecutionStates } from 'features/nodes/hooks/useExecutionState';
|
||||
import { $nodeExecutionStates } from 'features/nodes/hooks/useNodeExecutionState';
|
||||
import { workflowLoaded, workflowLoadRequested } from 'features/nodes/store/actions';
|
||||
import { $templates } from 'features/nodes/store/nodesSlice';
|
||||
import { $needsFit } from 'features/nodes/store/reactFlowInstance';
|
||||
@@ -22,12 +22,18 @@ const getWorkflow = async (data: GraphAndWorkflowResponse, templates: Templates)
|
||||
if (data.workflow) {
|
||||
// Prefer to load the workflow if it's available - it has more information
|
||||
const parsed = JSON.parse(data.workflow);
|
||||
return await validateWorkflow(parsed, templates, checkImageAccess, checkBoardAccess, checkModelAccess);
|
||||
return await validateWorkflow({
|
||||
workflow: parsed,
|
||||
templates,
|
||||
checkImageAccess,
|
||||
checkBoardAccess,
|
||||
checkModelAccess,
|
||||
});
|
||||
} else if (data.graph) {
|
||||
// Else we fall back on the graph, using the graphToWorkflow function to convert and do layout
|
||||
const parsed = JSON.parse(data.graph);
|
||||
const workflow = graphToWorkflow(parsed as NonNullableGraph, true);
|
||||
return await validateWorkflow(workflow, templates, checkImageAccess, checkBoardAccess, checkModelAccess);
|
||||
return await validateWorkflow({ workflow, templates, checkImageAccess, checkBoardAccess, checkModelAccess });
|
||||
} else {
|
||||
throw new Error('No workflow or graph provided');
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ export type AppFeature =
|
||||
| 'modelCache'
|
||||
| 'bulkDownload'
|
||||
| 'starterModels'
|
||||
| 'hfToken';
|
||||
| 'hfToken'
|
||||
| 'retryQueueItem';
|
||||
/**
|
||||
* A disable-able Stable Diffusion feature
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { As, ChakraProps, FlexProps } from '@invoke-ai/ui-library';
|
||||
import type { ChakraProps, FlexProps } from '@invoke-ai/ui-library';
|
||||
import { Flex, Icon, Skeleton, Spinner, Text } from '@invoke-ai/ui-library';
|
||||
import type { ElementType } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { PiImageBold } from 'react-icons/pi';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
@@ -28,7 +29,7 @@ IAILoadingImageFallback.displayName = 'IAILoadingImageFallback';
|
||||
|
||||
type IAINoImageFallbackProps = FlexProps & {
|
||||
label?: string;
|
||||
icon?: As | null;
|
||||
icon?: ElementType | null;
|
||||
boxSize?: ChakraProps['boxSize'];
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,18 @@ import { memo } from 'react';
|
||||
|
||||
const Loading = () => {
|
||||
return (
|
||||
<Flex position="relative" width="100dvw" height="100dvh" alignItems="center" justifyContent="center" bg="#151519">
|
||||
<Flex
|
||||
position="absolute"
|
||||
width="100dvw"
|
||||
height="100dvh"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="#151519"
|
||||
top={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
left={0}
|
||||
>
|
||||
<Image src={InvokeLogoWhite} w="8rem" h="8rem" />
|
||||
<Spinner
|
||||
label="Loading"
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Box } from '@invoke-ai/ui-library';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
type Props = {
|
||||
isSelected: boolean;
|
||||
isHovered: boolean;
|
||||
};
|
||||
const SelectionOverlay = ({ isSelected, isHovered }: Props) => {
|
||||
const shadow = useMemo(() => {
|
||||
if (isSelected && isHovered) {
|
||||
return 'nodeHoveredSelected';
|
||||
}
|
||||
if (isSelected) {
|
||||
return 'nodeSelected';
|
||||
}
|
||||
if (isHovered) {
|
||||
return 'nodeHovered';
|
||||
}
|
||||
return undefined;
|
||||
}, [isHovered, isSelected]);
|
||||
return (
|
||||
<Box
|
||||
className="selection-box"
|
||||
position="absolute"
|
||||
top={0}
|
||||
insetInlineEnd={0}
|
||||
bottom={0}
|
||||
insetInlineStart={0}
|
||||
borderRadius="base"
|
||||
opacity={isSelected || isHovered ? 1 : 0.5}
|
||||
transitionProperty="common"
|
||||
transitionDuration="0.1s"
|
||||
pointerEvents="none"
|
||||
shadow={shadow}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(SelectionOverlay);
|
||||
17
invokeai/frontend/web/src/common/components/linkify.ts
Normal file
17
invokeai/frontend/web/src/common/components/linkify.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import type { Opts as LinkifyOpts } from 'linkifyjs';
|
||||
|
||||
export const linkifySx: SystemStyleObject = {
|
||||
a: {
|
||||
fontWeight: 'semibold',
|
||||
},
|
||||
'a:hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
};
|
||||
|
||||
export const linkifyOptions: LinkifyOpts = {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
validate: (value) => /^https?:\/\//.test(value),
|
||||
};
|
||||
@@ -1,238 +0,0 @@
|
||||
import { useToken } from '@invoke-ai/ui-library';
|
||||
|
||||
export const useChakraThemeTokens = () => {
|
||||
const [
|
||||
base50,
|
||||
base100,
|
||||
base150,
|
||||
base200,
|
||||
base250,
|
||||
base300,
|
||||
base350,
|
||||
base400,
|
||||
base450,
|
||||
base500,
|
||||
base550,
|
||||
base600,
|
||||
base650,
|
||||
base700,
|
||||
base750,
|
||||
base800,
|
||||
base850,
|
||||
base900,
|
||||
base950,
|
||||
accent50,
|
||||
accent100,
|
||||
accent150,
|
||||
accent200,
|
||||
accent250,
|
||||
accent300,
|
||||
accent350,
|
||||
accent400,
|
||||
accent450,
|
||||
accent500,
|
||||
accent550,
|
||||
accent600,
|
||||
accent650,
|
||||
accent700,
|
||||
accent750,
|
||||
accent800,
|
||||
accent850,
|
||||
accent900,
|
||||
accent950,
|
||||
baseAlpha50,
|
||||
baseAlpha100,
|
||||
baseAlpha150,
|
||||
baseAlpha200,
|
||||
baseAlpha250,
|
||||
baseAlpha300,
|
||||
baseAlpha350,
|
||||
baseAlpha400,
|
||||
baseAlpha450,
|
||||
baseAlpha500,
|
||||
baseAlpha550,
|
||||
baseAlpha600,
|
||||
baseAlpha650,
|
||||
baseAlpha700,
|
||||
baseAlpha750,
|
||||
baseAlpha800,
|
||||
baseAlpha850,
|
||||
baseAlpha900,
|
||||
baseAlpha950,
|
||||
accentAlpha50,
|
||||
accentAlpha100,
|
||||
accentAlpha150,
|
||||
accentAlpha200,
|
||||
accentAlpha250,
|
||||
accentAlpha300,
|
||||
accentAlpha350,
|
||||
accentAlpha400,
|
||||
accentAlpha450,
|
||||
accentAlpha500,
|
||||
accentAlpha550,
|
||||
accentAlpha600,
|
||||
accentAlpha650,
|
||||
accentAlpha700,
|
||||
accentAlpha750,
|
||||
accentAlpha800,
|
||||
accentAlpha850,
|
||||
accentAlpha900,
|
||||
accentAlpha950,
|
||||
] = useToken('colors', [
|
||||
'base.50',
|
||||
'base.100',
|
||||
'base.150',
|
||||
'base.200',
|
||||
'base.250',
|
||||
'base.300',
|
||||
'base.350',
|
||||
'base.400',
|
||||
'base.450',
|
||||
'base.500',
|
||||
'base.550',
|
||||
'base.600',
|
||||
'base.650',
|
||||
'base.700',
|
||||
'base.750',
|
||||
'base.800',
|
||||
'base.850',
|
||||
'base.900',
|
||||
'base.950',
|
||||
'accent.50',
|
||||
'accent.100',
|
||||
'accent.150',
|
||||
'accent.200',
|
||||
'accent.250',
|
||||
'accent.300',
|
||||
'accent.350',
|
||||
'accent.400',
|
||||
'accent.450',
|
||||
'accent.500',
|
||||
'accent.550',
|
||||
'accent.600',
|
||||
'accent.650',
|
||||
'accent.700',
|
||||
'accent.750',
|
||||
'accent.800',
|
||||
'accent.850',
|
||||
'accent.900',
|
||||
'accent.950',
|
||||
'baseAlpha.50',
|
||||
'baseAlpha.100',
|
||||
'baseAlpha.150',
|
||||
'baseAlpha.200',
|
||||
'baseAlpha.250',
|
||||
'baseAlpha.300',
|
||||
'baseAlpha.350',
|
||||
'baseAlpha.400',
|
||||
'baseAlpha.450',
|
||||
'baseAlpha.500',
|
||||
'baseAlpha.550',
|
||||
'baseAlpha.600',
|
||||
'baseAlpha.650',
|
||||
'baseAlpha.700',
|
||||
'baseAlpha.750',
|
||||
'baseAlpha.800',
|
||||
'baseAlpha.850',
|
||||
'baseAlpha.900',
|
||||
'baseAlpha.950',
|
||||
'accentAlpha.50',
|
||||
'accentAlpha.100',
|
||||
'accentAlpha.150',
|
||||
'accentAlpha.200',
|
||||
'accentAlpha.250',
|
||||
'accentAlpha.300',
|
||||
'accentAlpha.350',
|
||||
'accentAlpha.400',
|
||||
'accentAlpha.450',
|
||||
'accentAlpha.500',
|
||||
'accentAlpha.550',
|
||||
'accentAlpha.600',
|
||||
'accentAlpha.650',
|
||||
'accentAlpha.700',
|
||||
'accentAlpha.750',
|
||||
'accentAlpha.800',
|
||||
'accentAlpha.850',
|
||||
'accentAlpha.900',
|
||||
'accentAlpha.950',
|
||||
]);
|
||||
|
||||
return {
|
||||
base50,
|
||||
base100,
|
||||
base150,
|
||||
base200,
|
||||
base250,
|
||||
base300,
|
||||
base350,
|
||||
base400,
|
||||
base450,
|
||||
base500,
|
||||
base550,
|
||||
base600,
|
||||
base650,
|
||||
base700,
|
||||
base750,
|
||||
base800,
|
||||
base850,
|
||||
base900,
|
||||
base950,
|
||||
accent50,
|
||||
accent100,
|
||||
accent150,
|
||||
accent200,
|
||||
accent250,
|
||||
accent300,
|
||||
accent350,
|
||||
accent400,
|
||||
accent450,
|
||||
accent500,
|
||||
accent550,
|
||||
accent600,
|
||||
accent650,
|
||||
accent700,
|
||||
accent750,
|
||||
accent800,
|
||||
accent850,
|
||||
accent900,
|
||||
accent950,
|
||||
baseAlpha50,
|
||||
baseAlpha100,
|
||||
baseAlpha150,
|
||||
baseAlpha200,
|
||||
baseAlpha250,
|
||||
baseAlpha300,
|
||||
baseAlpha350,
|
||||
baseAlpha400,
|
||||
baseAlpha450,
|
||||
baseAlpha500,
|
||||
baseAlpha550,
|
||||
baseAlpha600,
|
||||
baseAlpha650,
|
||||
baseAlpha700,
|
||||
baseAlpha750,
|
||||
baseAlpha800,
|
||||
baseAlpha850,
|
||||
baseAlpha900,
|
||||
baseAlpha950,
|
||||
accentAlpha50,
|
||||
accentAlpha100,
|
||||
accentAlpha150,
|
||||
accentAlpha200,
|
||||
accentAlpha250,
|
||||
accentAlpha300,
|
||||
accentAlpha350,
|
||||
accentAlpha400,
|
||||
accentAlpha450,
|
||||
accentAlpha500,
|
||||
accentAlpha550,
|
||||
accentAlpha600,
|
||||
accentAlpha650,
|
||||
accentAlpha700,
|
||||
accentAlpha750,
|
||||
accentAlpha800,
|
||||
accentAlpha850,
|
||||
accentAlpha900,
|
||||
accentAlpha950,
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useClipboard } from 'common/hooks/useClipboard';
|
||||
import { convertImageUrlToBlob } from 'common/util/convertImageUrlToBlob';
|
||||
import { imageCopiedToClipboard } from 'features/gallery/store/actions';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -7,6 +9,7 @@ import { useTranslation } from 'react-i18next';
|
||||
export const useCopyImageToClipboard = () => {
|
||||
const { t } = useTranslation();
|
||||
const clipboard = useClipboard();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const copyImageToClipboard = useCallback(
|
||||
async (image_url: string) => {
|
||||
@@ -23,6 +26,7 @@ export const useCopyImageToClipboard = () => {
|
||||
title: t('toast.imageCopied'),
|
||||
status: 'success',
|
||||
});
|
||||
dispatch(imageCopiedToClipboard());
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
@@ -33,7 +37,7 @@ export const useCopyImageToClipboard = () => {
|
||||
});
|
||||
}
|
||||
},
|
||||
[clipboard, t]
|
||||
[clipboard, t, dispatch]
|
||||
);
|
||||
|
||||
return copyImageToClipboard;
|
||||
|
||||
72
invokeai/frontend/web/src/common/hooks/useEditable.ts
Normal file
72
invokeai/frontend/web/src/common/hooks/useEditable.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { ChangeEvent, KeyboardEvent, RefObject } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
type UseEditableArg = {
|
||||
value: string;
|
||||
defaultValue: string;
|
||||
onChange: (value: string) => void;
|
||||
onStartEditing?: () => void;
|
||||
inputRef?: RefObject<HTMLInputElement | HTMLTextAreaElement>;
|
||||
};
|
||||
|
||||
export const useEditable = ({ value, defaultValue, onChange: _onChange, onStartEditing, inputRef }: UseEditableArg) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
const trimmedValue = localValue.trim();
|
||||
const newValue = trimmedValue || defaultValue;
|
||||
setLocalValue(newValue);
|
||||
if (newValue !== value) {
|
||||
_onChange(newValue);
|
||||
}
|
||||
setIsEditing(false);
|
||||
inputRef?.current?.setSelectionRange(0, 0);
|
||||
}, [localValue, defaultValue, value, inputRef, _onChange]);
|
||||
|
||||
const onChange = useCallback((e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setLocalValue(e.target.value);
|
||||
}, []);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
onBlur();
|
||||
} else if (e.key === 'Escape') {
|
||||
setLocalValue(value);
|
||||
_onChange(value);
|
||||
setIsEditing(false);
|
||||
}
|
||||
},
|
||||
[_onChange, onBlur, value]
|
||||
);
|
||||
|
||||
const startEditing = useCallback(() => {
|
||||
setIsEditing(true);
|
||||
onStartEditing?.();
|
||||
}, [onStartEditing]);
|
||||
|
||||
useEffect(() => {
|
||||
// Another component may change the title; sync local title with global state
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
inputRef?.current?.focus();
|
||||
inputRef?.current?.select();
|
||||
}
|
||||
}, [inputRef, isEditing]);
|
||||
|
||||
return {
|
||||
isEditing,
|
||||
startEditing,
|
||||
value: localValue,
|
||||
inputProps: {
|
||||
value: localValue,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onBlur,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -128,7 +128,11 @@ export const useImageUploadButton = ({ onUpload, isDisabled, allowMultiple }: Us
|
||||
getInputProps: getUploadInputProps,
|
||||
open: openUploader,
|
||||
} = useDropzone({
|
||||
accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg', '.png'] },
|
||||
accept: {
|
||||
'image/png': ['.png'],
|
||||
'image/jpeg': ['.jpg', '.jpeg', '.png'],
|
||||
'image/webp': ['.webp'],
|
||||
},
|
||||
onDropAccepted,
|
||||
onDropRejected,
|
||||
disabled: isDisabled,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Flex, IconButton } from '@invoke-ai/ui-library';
|
||||
import { createMemoizedAppSelector } from 'app/store/createMemoizedSelector';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
|
||||
@@ -34,7 +34,7 @@ import type {
|
||||
} from 'services/api/types';
|
||||
|
||||
const buildSelectControlAdapter = (entityIdentifier: CanvasEntityIdentifier<'control_layer'>) =>
|
||||
createMemoizedAppSelector(selectCanvasSlice, (canvas) => {
|
||||
createSelector(selectCanvasSlice, (canvas) => {
|
||||
const layer = selectEntityOrThrow(canvas, entityIdentifier, 'ControlLayerControlAdapter');
|
||||
return layer.controlAdapter;
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Button, Flex, Text } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
|
||||
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
|
||||
import { usePullBboxIntoGlobalReferenceImage } from 'features/controlLayers/hooks/saveCanvasHooks';
|
||||
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
|
||||
import type { SetGlobalReferenceImageDndTargetData } from 'features/dnd/dnd';
|
||||
import { setGlobalReferenceImageDndTarget } from 'features/dnd/dnd';
|
||||
@@ -27,6 +28,7 @@ export const IPAdapterSettingsEmptyState = memo(() => {
|
||||
const onClickGalleryButton = useCallback(() => {
|
||||
dispatch(activeTabCanvasRightPanelChanged('gallery'));
|
||||
}, [dispatch]);
|
||||
const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(entityIdentifier);
|
||||
|
||||
const dndTargetData = useMemo<SetGlobalReferenceImageDndTargetData>(
|
||||
() => setGlobalReferenceImageDndTarget.getData({ entityIdentifier }),
|
||||
@@ -41,8 +43,11 @@ export const IPAdapterSettingsEmptyState = memo(() => {
|
||||
GalleryButton: (
|
||||
<Button onClick={onClickGalleryButton} isDisabled={isBusy} size="sm" variant="link" color="base.300" />
|
||||
),
|
||||
PullBboxButton: (
|
||||
<Button onClick={pullBboxIntoIPAdapter} isDisabled={isBusy} size="sm" variant="link" color="base.300" />
|
||||
),
|
||||
}),
|
||||
[isBusy, onClickGalleryButton, uploadApi]
|
||||
[isBusy, onClickGalleryButton, pullBboxIntoIPAdapter, uploadApi]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Button, Flex, IconButton, Spacer, Text } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
|
||||
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
|
||||
import { usePullBboxIntoRegionalGuidanceReferenceImage } from 'features/controlLayers/hooks/saveCanvasHooks';
|
||||
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
|
||||
import { rgIPAdapterDeleted } from 'features/controlLayers/store/canvasSlice';
|
||||
import type { SetRegionalGuidanceReferenceImageDndTargetData } from 'features/dnd/dnd';
|
||||
@@ -36,6 +37,7 @@ export const RegionalGuidanceIPAdapterSettingsEmptyState = memo(({ referenceImag
|
||||
const onDeleteIPAdapter = useCallback(() => {
|
||||
dispatch(rgIPAdapterDeleted({ entityIdentifier, referenceImageId }));
|
||||
}, [dispatch, entityIdentifier, referenceImageId]);
|
||||
const pullBboxIntoIPAdapter = usePullBboxIntoRegionalGuidanceReferenceImage(entityIdentifier, referenceImageId);
|
||||
|
||||
const dndTargetData = useMemo<SetRegionalGuidanceReferenceImageDndTargetData>(
|
||||
() =>
|
||||
@@ -46,6 +48,21 @@ export const RegionalGuidanceIPAdapterSettingsEmptyState = memo(({ referenceImag
|
||||
[entityIdentifier, referenceImageId]
|
||||
);
|
||||
|
||||
const components = useMemo(
|
||||
() => ({
|
||||
UploadButton: (
|
||||
<Button isDisabled={isBusy} size="sm" variant="link" color="base.300" {...uploadApi.getUploadButtonProps()} />
|
||||
),
|
||||
GalleryButton: (
|
||||
<Button onClick={onClickGalleryButton} isDisabled={isBusy} size="sm" variant="link" color="base.300" />
|
||||
),
|
||||
PullBboxButton: (
|
||||
<Button onClick={pullBboxIntoIPAdapter} isDisabled={isBusy} size="sm" variant="link" color="base.300" />
|
||||
),
|
||||
}),
|
||||
[isBusy, onClickGalleryButton, pullBboxIntoIPAdapter, uploadApi]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" gap={2} position="relative" w="full">
|
||||
<Flex alignItems="center" gap={2}>
|
||||
@@ -66,23 +83,7 @@ export const RegionalGuidanceIPAdapterSettingsEmptyState = memo(({ referenceImag
|
||||
</Flex>
|
||||
<Flex alignItems="center" gap={2} p={4}>
|
||||
<Text textAlign="center" color="base.300">
|
||||
<Trans
|
||||
i18nKey="controlLayers.referenceImageEmptyState"
|
||||
components={{
|
||||
UploadButton: (
|
||||
<Button
|
||||
isDisabled={isBusy}
|
||||
size="sm"
|
||||
variant="link"
|
||||
color="base.300"
|
||||
{...uploadApi.getUploadButtonProps()}
|
||||
/>
|
||||
),
|
||||
GalleryButton: (
|
||||
<Button onClick={onClickGalleryButton} isDisabled={isBusy} size="sm" variant="link" color="base.300" />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Trans i18nKey="controlLayers.referenceImageEmptyState" components={components} />
|
||||
</Text>
|
||||
</Flex>
|
||||
<input {...uploadApi.getUploadInputProps()} />
|
||||
|
||||
@@ -1,67 +1,43 @@
|
||||
import { Input } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useBoolean } from 'common/hooks/useBoolean';
|
||||
import { useEditable } from 'common/hooks/useEditable';
|
||||
import { CanvasEntityTitle } from 'features/controlLayers/components/common/CanvasEntityTitle';
|
||||
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
|
||||
import { useEntityTitle } from 'features/controlLayers/hooks/useEntityTitle';
|
||||
import { useEntityName, useEntityTypeName } from 'features/controlLayers/hooks/useEntityTitle';
|
||||
import { entityNameChanged } from 'features/controlLayers/store/canvasSlice';
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
|
||||
export const CanvasEntityEditableTitle = memo(() => {
|
||||
const dispatch = useAppDispatch();
|
||||
const entityIdentifier = useEntityIdentifierContext();
|
||||
const title = useEntityTitle(entityIdentifier);
|
||||
const isEditing = useBoolean(false);
|
||||
const [localTitle, setLocalTitle] = useState(title);
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const name = useEntityName(entityIdentifier);
|
||||
const typeName = useEntityTypeName(entityIdentifier.type);
|
||||
|
||||
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalTitle(e.target.value);
|
||||
}, []);
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
const trimmedTitle = localTitle.trim();
|
||||
if (trimmedTitle.length === 0) {
|
||||
dispatch(entityNameChanged({ entityIdentifier, name: null }));
|
||||
} else if (trimmedTitle !== title) {
|
||||
dispatch(entityNameChanged({ entityIdentifier, name: trimmedTitle }));
|
||||
}
|
||||
isEditing.setFalse();
|
||||
}, [dispatch, entityIdentifier, isEditing, localTitle, title]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
onBlur();
|
||||
} else if (e.key === 'Escape') {
|
||||
setLocalTitle(title);
|
||||
isEditing.setFalse();
|
||||
}
|
||||
const onChange = useCallback(
|
||||
(name: string) => {
|
||||
dispatch(entityNameChanged({ entityIdentifier, name }));
|
||||
},
|
||||
[isEditing, onBlur, title]
|
||||
[dispatch, entityIdentifier]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing.isTrue) {
|
||||
ref.current?.focus();
|
||||
ref.current?.select();
|
||||
}
|
||||
}, [isEditing.isTrue]);
|
||||
const editable = useEditable({
|
||||
value: name || typeName,
|
||||
defaultValue: typeName,
|
||||
onChange,
|
||||
inputRef,
|
||||
});
|
||||
|
||||
if (!isEditing.isTrue) {
|
||||
return <CanvasEntityTitle cursor="text" onDoubleClick={isEditing.setTrue} />;
|
||||
if (!editable.isEditing) {
|
||||
return <CanvasEntityTitle cursor="text" onDoubleClick={editable.startEditing} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
value={localTitle}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
ref={inputRef}
|
||||
{...editable.inputProps}
|
||||
variant="outline"
|
||||
_focusVisible={{ borderWidth: 1, borderColor: 'invokeBlueAlpha.400', borderRadius: 'base' }}
|
||||
_focusVisible={{ borderRadius: 'base', h: 'unset' }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -15,17 +15,17 @@ const createSelectName = (entityIdentifier: CanvasEntityIdentifier) =>
|
||||
return entity.name;
|
||||
});
|
||||
|
||||
export const useEntityTitle = (entityIdentifier: CanvasEntityIdentifier) => {
|
||||
const { t } = useTranslation();
|
||||
export const useEntityName = (entityIdentifier: CanvasEntityIdentifier) => {
|
||||
const selectName = useMemo(() => createSelectName(entityIdentifier), [entityIdentifier]);
|
||||
const name = useAppSelector(selectName);
|
||||
return name;
|
||||
};
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
export const useEntityTypeName = (type: CanvasEntityIdentifier['type']) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
switch (entityIdentifier.type) {
|
||||
const typeName = useMemo(() => {
|
||||
switch (type) {
|
||||
case 'inpaint_mask':
|
||||
return t('controlLayers.inpaintMask');
|
||||
case 'control_layer':
|
||||
@@ -39,7 +39,15 @@ export const useEntityTitle = (entityIdentifier: CanvasEntityIdentifier) => {
|
||||
default:
|
||||
assert(false, 'Unexpected entity type');
|
||||
}
|
||||
}, [entityIdentifier.type, name, t]);
|
||||
}, [type, t]);
|
||||
|
||||
return typeName;
|
||||
};
|
||||
|
||||
export const useEntityTitle = (entityIdentifier: CanvasEntityIdentifier) => {
|
||||
const name = useEntityName(entityIdentifier);
|
||||
const typeName = useEntityTypeName(entityIdentifier.type);
|
||||
const title = useMemo(() => name || typeName, [name, typeName]);
|
||||
|
||||
return title;
|
||||
};
|
||||
|
||||
@@ -59,11 +59,11 @@ export class CanvasEntityAdapterInpaintMask extends CanvasEntityAdapterBase<
|
||||
this.syncOpacity();
|
||||
}
|
||||
if (!prevState || this.state.fill !== prevState.fill) {
|
||||
// On first render, we must force the update
|
||||
this.renderer.updateCompositingRectFill(!prevState);
|
||||
// On first render, or when the fill changes, we must force the update
|
||||
this.renderer.updateCompositingRectFill(true);
|
||||
}
|
||||
if (!prevState) {
|
||||
// On first render, we must force the updates
|
||||
if (!prevState || this.state.objects !== prevState.objects) {
|
||||
// On first render, or when the objects change, we must force the update
|
||||
this.renderer.updateCompositingRectSize(true);
|
||||
this.renderer.updateCompositingRectPosition(true);
|
||||
}
|
||||
|
||||
@@ -59,11 +59,11 @@ export class CanvasEntityAdapterRegionalGuidance extends CanvasEntityAdapterBase
|
||||
this.syncOpacity();
|
||||
}
|
||||
if (!prevState || this.state.fill !== prevState.fill) {
|
||||
// On first render, we must force the update
|
||||
this.renderer.updateCompositingRectFill(!prevState);
|
||||
// On first render, or when the fill changes, we must force the update
|
||||
this.renderer.updateCompositingRectFill(true);
|
||||
}
|
||||
if (!prevState) {
|
||||
// On first render, we must force the updates
|
||||
if (!prevState || this.state.objects !== prevState.objects) {
|
||||
// On first render, or when the objects change, we must force the update
|
||||
this.renderer.updateCompositingRectSize(true);
|
||||
this.renderer.updateCompositingRectPosition(true);
|
||||
}
|
||||
|
||||
@@ -284,8 +284,8 @@ export class CanvasEntityFilterer extends CanvasModuleBase {
|
||||
this.log.error({ error: serializeError(filterResult.error) }, 'Error filtering');
|
||||
this.$isProcessing.set(false);
|
||||
// Clean up the abort controller as needed
|
||||
if (!this.abortController.signal.aborted) {
|
||||
this.abortController.abort();
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort();
|
||||
}
|
||||
this.abortController = null;
|
||||
return;
|
||||
@@ -324,8 +324,8 @@ export class CanvasEntityFilterer extends CanvasModuleBase {
|
||||
this.$isProcessing.set(false);
|
||||
|
||||
// Clean up the abort controller as needed
|
||||
if (!this.abortController.signal.aborted) {
|
||||
this.abortController.abort();
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort();
|
||||
}
|
||||
|
||||
this.abortController = null;
|
||||
|
||||
@@ -277,8 +277,11 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
|
||||
|
||||
let points: number[];
|
||||
|
||||
let isShiftDraw = false;
|
||||
|
||||
if (e.evt.shiftKey && lastLinePoint) {
|
||||
// Create a straight line from the last line point
|
||||
isShiftDraw = true;
|
||||
points = [
|
||||
lastLinePoint.x,
|
||||
lastLinePoint.y,
|
||||
@@ -298,15 +301,18 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
|
||||
points,
|
||||
strokeWidth: settings.brushWidth,
|
||||
color: this.manager.stateApi.getCurrentColor(),
|
||||
clip: this.parent.getClip(selectedEntity.state),
|
||||
// When shift is held, the line may extend beyond the clip region. No clip for these lines.
|
||||
clip: isShiftDraw ? null : this.parent.getClip(selectedEntity.state),
|
||||
});
|
||||
} else {
|
||||
const lastLinePoint = getLastPointOfLastLine(selectedEntity.state.objects, 'brush_line');
|
||||
|
||||
let points: number[];
|
||||
let isShiftDraw = false;
|
||||
|
||||
if (e.evt.shiftKey && lastLinePoint) {
|
||||
// Create a straight line from the last line point
|
||||
isShiftDraw = true;
|
||||
points = [lastLinePoint.x, lastLinePoint.y, alignedPoint.x, alignedPoint.y];
|
||||
} else {
|
||||
// Create a new line with the current point
|
||||
@@ -319,7 +325,8 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
|
||||
points,
|
||||
strokeWidth: settings.brushWidth,
|
||||
color: this.manager.stateApi.getCurrentColor(),
|
||||
clip: this.parent.getClip(selectedEntity.state),
|
||||
// When shift is held, the line may extend beyond the clip region. No clip for these lines.
|
||||
clip: isShiftDraw ? null : this.parent.getClip(selectedEntity.state),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import type { CanvasToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasToolModule';
|
||||
import { getColorAtCoordinate, getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import type { RgbColor } from 'features/controlLayers/store/types';
|
||||
import type { RgbaColor } from 'features/controlLayers/store/types';
|
||||
import { RGBA_BLACK } from 'features/controlLayers/store/types';
|
||||
import Konva from 'konva';
|
||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||
@@ -52,6 +52,39 @@ type CanvasColorPickerToolModuleConfig = {
|
||||
* The color of the crosshair line borders.
|
||||
*/
|
||||
CROSSHAIR_BORDER_COLOR: string;
|
||||
/**
|
||||
* The color of the RGBA value text.
|
||||
*/
|
||||
TEXT_COLOR: string;
|
||||
/**
|
||||
* The padding of the RGBA value text within the background rect.
|
||||
*/
|
||||
|
||||
TEXT_PADDING: number;
|
||||
/**
|
||||
* The font size of the RGBA value text.
|
||||
*/
|
||||
TEXT_FONT_SIZE: number;
|
||||
/**
|
||||
* The color of the RGBA value text background rect.
|
||||
*/
|
||||
TEXT_BG_COLOR: string;
|
||||
/**
|
||||
* The width of the RGBA value text background rect.
|
||||
*/
|
||||
TEXT_BG_WIDTH: number;
|
||||
/**
|
||||
* The height of the RGBA value text background rect.
|
||||
*/
|
||||
TEXT_BG_HEIGHT: number;
|
||||
/**
|
||||
* The corner radius of the RGBA value text background rect.
|
||||
*/
|
||||
TEXT_BG_CORNER_RADIUS: number;
|
||||
/**
|
||||
* The x offset of the RGBA value text background rect from the color picker ring.
|
||||
*/
|
||||
TEXT_BG_X_OFFSET: number;
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: CanvasColorPickerToolModuleConfig = {
|
||||
@@ -65,6 +98,14 @@ const DEFAULT_CONFIG: CanvasColorPickerToolModuleConfig = {
|
||||
CROSSHAIR_LINE_LENGTH: 10,
|
||||
CROSSHAIR_LINE_COLOR: 'rgba(0,0,0,1)',
|
||||
CROSSHAIR_BORDER_COLOR: 'rgba(255,255,255,0.8)',
|
||||
TEXT_COLOR: 'rgba(255,255,255,1)',
|
||||
TEXT_BG_COLOR: 'rgba(0,0,0,0.8)',
|
||||
TEXT_BG_HEIGHT: 62,
|
||||
TEXT_BG_WIDTH: 62,
|
||||
TEXT_BG_CORNER_RADIUS: 7,
|
||||
TEXT_PADDING: 8,
|
||||
TEXT_FONT_SIZE: 12,
|
||||
TEXT_BG_X_OFFSET: 7,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -83,7 +124,7 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
|
||||
/**
|
||||
* The color currently under the cursor. Only has a value when the color picker tool is active.
|
||||
*/
|
||||
$colorUnderCursor = atom<RgbColor>(RGBA_BLACK);
|
||||
$colorUnderCursor = atom<RgbaColor>(RGBA_BLACK);
|
||||
|
||||
/**
|
||||
* The Konva objects that make up the color picker tool preview:
|
||||
@@ -105,6 +146,9 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
|
||||
crosshairSouthOuter: Konva.Line;
|
||||
crosshairWestInner: Konva.Line;
|
||||
crosshairWestOuter: Konva.Line;
|
||||
rgbaTextGroup: Konva.Group;
|
||||
rgbaText: Konva.Text;
|
||||
rgbaTextBackground: Konva.Rect;
|
||||
};
|
||||
|
||||
constructor(parent: CanvasToolModule) {
|
||||
@@ -202,8 +246,28 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
|
||||
stroke: this.config.CROSSHAIR_BORDER_COLOR,
|
||||
perfectDrawEnabled: false,
|
||||
}),
|
||||
rgbaTextGroup: new Konva.Group({
|
||||
listening: false,
|
||||
name: `${this.type}:color_picker_text_group`,
|
||||
}),
|
||||
rgbaText: new Konva.Text({
|
||||
listening: false,
|
||||
name: `${this.type}:color_picker_text`,
|
||||
fill: this.config.TEXT_COLOR,
|
||||
fontFamily: 'monospace',
|
||||
align: 'left',
|
||||
fontStyle: 'bold',
|
||||
verticalAlign: 'middle',
|
||||
}),
|
||||
rgbaTextBackground: new Konva.Rect({
|
||||
listening: false,
|
||||
name: `${this.type}:color_picker_text_background`,
|
||||
fill: this.config.TEXT_BG_COLOR,
|
||||
}),
|
||||
};
|
||||
|
||||
this.konva.rgbaTextGroup.add(this.konva.rgbaTextBackground, this.konva.rgbaText);
|
||||
|
||||
this.konva.group.add(
|
||||
this.konva.ringCandidateColor,
|
||||
this.konva.ringCurrentColor,
|
||||
@@ -216,7 +280,8 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
|
||||
this.konva.crosshairSouthOuter,
|
||||
this.konva.crosshairSouthInner,
|
||||
this.konva.crosshairWestOuter,
|
||||
this.konva.crosshairWestInner
|
||||
this.konva.crosshairWestInner,
|
||||
this.konva.rgbaTextGroup
|
||||
);
|
||||
}
|
||||
|
||||
@@ -233,11 +298,6 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.parent.getCanDraw()) {
|
||||
this.setVisibility(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorPos = this.parent.$cursorPos.get();
|
||||
|
||||
if (!cursorPos) {
|
||||
@@ -283,6 +343,24 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
|
||||
outerRadius: colorPickerOuterRadius + twoPixels,
|
||||
});
|
||||
|
||||
const textBgWidth = this.manager.stage.unscale(this.config.TEXT_BG_WIDTH);
|
||||
const textBgHeight = this.manager.stage.unscale(this.config.TEXT_BG_HEIGHT);
|
||||
|
||||
this.konva.rgbaTextBackground.setAttrs({
|
||||
width: textBgWidth,
|
||||
height: textBgHeight,
|
||||
cornerRadius: this.manager.stage.unscale(this.config.TEXT_BG_CORNER_RADIUS),
|
||||
});
|
||||
this.konva.rgbaText.setAttrs({
|
||||
padding: this.manager.stage.unscale(this.config.TEXT_PADDING),
|
||||
fontSize: this.manager.stage.unscale(this.config.TEXT_FONT_SIZE),
|
||||
text: `R: ${colorUnderCursor.r}\nG: ${colorUnderCursor.g}\nB: ${colorUnderCursor.b}\nA: ${colorUnderCursor.a}`,
|
||||
});
|
||||
this.konva.rgbaTextGroup.setAttrs({
|
||||
x: x + this.manager.stage.unscale(this.config.RING_OUTER_RADIUS + this.config.TEXT_BG_X_OFFSET),
|
||||
y: y - textBgHeight / 2,
|
||||
});
|
||||
|
||||
const size = this.manager.stage.unscale(this.config.CROSSHAIR_LINE_LENGTH);
|
||||
const space = this.manager.stage.unscale(this.config.CROSSHAIR_INNER_RADIUS);
|
||||
const innerThickness = this.manager.stage.unscale(this.config.CROSSHAIR_LINE_THICKNESS);
|
||||
@@ -329,11 +407,8 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
|
||||
|
||||
onStagePointerUp = (_e: KonvaEventObject<PointerEvent>) => {
|
||||
const color = this.$colorUnderCursor.get();
|
||||
if (color) {
|
||||
const settings = this.manager.stateApi.getSettings();
|
||||
// This will update the color but not the alpha value
|
||||
this.manager.stateApi.setColor({ ...settings.color, ...color });
|
||||
}
|
||||
const settings = this.manager.stateApi.getSettings();
|
||||
this.manager.stateApi.setColor({ ...settings.color, ...color });
|
||||
};
|
||||
|
||||
onStagePointerMove = (_e: KonvaEventObject<PointerEvent>) => {
|
||||
@@ -346,7 +421,11 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide the background layer so we can get the color under the cursor without the grid interfering
|
||||
this.manager.background.konva.layer.visible(false);
|
||||
const color = getColorAtCoordinate(this.manager.stage.konva.stage, cursorPos.absolute);
|
||||
this.manager.background.konva.layer.visible(true);
|
||||
|
||||
if (color) {
|
||||
this.$colorUnderCursor.set(color);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
Coordinate,
|
||||
Tool,
|
||||
} from 'features/controlLayers/store/types';
|
||||
import { isRenderableEntityType } from 'features/controlLayers/store/types';
|
||||
import Konva from 'konva';
|
||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||
import { atom } from 'nanostores';
|
||||
@@ -177,24 +178,26 @@ export class CanvasToolModule extends CanvasModuleBase {
|
||||
stage.setCursor('not-allowed');
|
||||
} else if (tool === 'bbox') {
|
||||
this.tools.bbox.syncCursorStyle();
|
||||
} else if (this.manager.stateApi.getRenderedEntityCount() === 0) {
|
||||
stage.setCursor('not-allowed');
|
||||
} else if (selectedEntityAdapter?.$isDisabled.get()) {
|
||||
stage.setCursor('not-allowed');
|
||||
} else if (selectedEntityAdapter?.$isEntityTypeHidden.get()) {
|
||||
stage.setCursor('not-allowed');
|
||||
} else if (selectedEntityAdapter?.$isLocked.get()) {
|
||||
stage.setCursor('not-allowed');
|
||||
} else if (tool === 'brush') {
|
||||
this.tools.brush.syncCursorStyle();
|
||||
} else if (tool === 'eraser') {
|
||||
this.tools.eraser.syncCursorStyle();
|
||||
} else if (tool === 'colorPicker') {
|
||||
this.tools.colorPicker.syncCursorStyle();
|
||||
} else if (tool === 'move') {
|
||||
this.tools.move.syncCursorStyle();
|
||||
} else if (tool === 'rect') {
|
||||
this.tools.rect.syncCursorStyle();
|
||||
} else if (selectedEntityAdapter && isRenderableEntityType(selectedEntityAdapter.entityIdentifier.type)) {
|
||||
if (selectedEntityAdapter.$isDisabled.get()) {
|
||||
stage.setCursor('not-allowed');
|
||||
} else if (selectedEntityAdapter.$isEntityTypeHidden.get()) {
|
||||
stage.setCursor('not-allowed');
|
||||
} else if (selectedEntityAdapter.$isLocked.get()) {
|
||||
stage.setCursor('not-allowed');
|
||||
} else if (tool === 'brush') {
|
||||
this.tools.brush.syncCursorStyle();
|
||||
} else if (tool === 'eraser') {
|
||||
this.tools.eraser.syncCursorStyle();
|
||||
} else if (tool === 'move') {
|
||||
this.tools.move.syncCursorStyle();
|
||||
} else if (tool === 'rect') {
|
||||
this.tools.rect.syncCursorStyle();
|
||||
}
|
||||
} else if (this.manager.stateApi.getRenderedEntityCount() === 0) {
|
||||
stage.setCursor('not-allowed');
|
||||
} else {
|
||||
stage.setCursor('not-allowed');
|
||||
}
|
||||
@@ -387,15 +390,17 @@ export class CanvasToolModule extends CanvasModuleBase {
|
||||
try {
|
||||
this.$lastPointerType.set(e.evt.pointerType);
|
||||
|
||||
if (!this.getCanDraw()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tool = this.$tool.get();
|
||||
|
||||
if (tool === 'colorPicker') {
|
||||
this.tools.colorPicker.onStagePointerUp(e);
|
||||
} else if (tool === 'brush') {
|
||||
}
|
||||
|
||||
if (!this.getCanDraw()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tool === 'brush') {
|
||||
this.tools.brush.onStagePointerUp(e);
|
||||
} else if (tool === 'eraser') {
|
||||
this.tools.eraser.onStagePointerUp(e);
|
||||
@@ -416,15 +421,17 @@ export class CanvasToolModule extends CanvasModuleBase {
|
||||
this.$lastPointerType.set(e.evt.pointerType);
|
||||
this.syncCursorPositions();
|
||||
|
||||
if (!this.getCanDraw()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tool = this.$tool.get();
|
||||
|
||||
if (tool === 'colorPicker') {
|
||||
this.tools.colorPicker.onStagePointerMove(e);
|
||||
} else if (tool === 'brush') {
|
||||
}
|
||||
|
||||
if (!this.getCanDraw()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tool === 'brush') {
|
||||
await this.tools.brush.onStagePointerMove(e);
|
||||
} else if (tool === 'eraser') {
|
||||
await this.tools.eraser.onStagePointerMove(e);
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
Coordinate,
|
||||
CoordinateWithPressure,
|
||||
Rect,
|
||||
RgbaColor,
|
||||
} from 'features/controlLayers/store/types';
|
||||
import type Konva from 'konva';
|
||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||
@@ -15,7 +16,6 @@ import { clamp } from 'lodash-es';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
import type { StrokeOptions } from 'perfect-freehand';
|
||||
import getStroke from 'perfect-freehand';
|
||||
import type { RgbColor } from 'react-colorful';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
/**
|
||||
@@ -484,9 +484,10 @@ export function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
export const nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 10);
|
||||
|
||||
export function getPrefixedId(
|
||||
prefix: CanvasEntityIdentifier['type'] | CanvasObjectState['type'] | (string & Record<never, never>)
|
||||
prefix: CanvasEntityIdentifier['type'] | CanvasObjectState['type'] | (string & Record<never, never>),
|
||||
separator = ':'
|
||||
): string {
|
||||
return `${prefix}:${nanoid()}`;
|
||||
return `${prefix}${separator}${nanoid()}`;
|
||||
}
|
||||
|
||||
export const getEmptyRect = (): Rect => {
|
||||
@@ -723,7 +724,7 @@ export const getPointerType = (e: KonvaEventObject<PointerEvent>): 'mouse' | 'pe
|
||||
* @param coord The coordinate to get the color at. This must be the _absolute_ coordinate on the stage.
|
||||
* @returns The color under the coordinate, or null if there was a problem getting the color.
|
||||
*/
|
||||
export const getColorAtCoordinate = (stage: Konva.Stage, coord: Coordinate): RgbColor | null => {
|
||||
export const getColorAtCoordinate = (stage: Konva.Stage, coord: Coordinate): RgbaColor | null => {
|
||||
const ctx = stage
|
||||
.toCanvas({ x: coord.x, y: coord.y, width: 1, height: 1, imageSmoothingEnabled: false })
|
||||
.getContext('2d');
|
||||
@@ -732,13 +733,13 @@ export const getColorAtCoordinate = (stage: Konva.Stage, coord: Coordinate): Rgb
|
||||
return null;
|
||||
}
|
||||
|
||||
const [r, g, b, _a] = ctx.getImageData(0, 0, 1, 1).data;
|
||||
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
|
||||
|
||||
if (r === undefined || g === undefined || b === undefined) {
|
||||
if (r === undefined || g === undefined || b === undefined || a === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { r, g, b };
|
||||
return { r, g, b, a };
|
||||
};
|
||||
|
||||
export const roundRect = (rect: Rect): Rect => {
|
||||
|
||||
@@ -49,6 +49,8 @@ export type ParamsState = {
|
||||
optimizedDenoisingEnabled: boolean;
|
||||
iterations: number;
|
||||
scheduler: ParameterScheduler;
|
||||
upscaleScheduler: ParameterScheduler;
|
||||
upscaleCfgScale: ParameterCFGScale;
|
||||
seed: ParameterSeed;
|
||||
shouldRandomizeSeed: boolean;
|
||||
steps: ParameterSteps;
|
||||
@@ -96,6 +98,8 @@ const initialState: ParamsState = {
|
||||
optimizedDenoisingEnabled: true,
|
||||
iterations: 1,
|
||||
scheduler: 'dpmpp_3m_k',
|
||||
upscaleScheduler: 'kdpm_2',
|
||||
upscaleCfgScale: 2,
|
||||
seed: 0,
|
||||
shouldRandomizeSeed: true,
|
||||
steps: 30,
|
||||
@@ -139,6 +143,9 @@ export const paramsSlice = createSlice({
|
||||
setCfgScale: (state, action: PayloadAction<ParameterCFGScale>) => {
|
||||
state.cfgScale = action.payload;
|
||||
},
|
||||
setUpscaleCfgScale: (state, action: PayloadAction<ParameterCFGScale>) => {
|
||||
state.upscaleCfgScale = action.payload;
|
||||
},
|
||||
setGuidance: (state, action: PayloadAction<ParameterGuidance>) => {
|
||||
state.guidance = action.payload;
|
||||
},
|
||||
@@ -148,6 +155,10 @@ export const paramsSlice = createSlice({
|
||||
setScheduler: (state, action: PayloadAction<ParameterScheduler>) => {
|
||||
state.scheduler = action.payload;
|
||||
},
|
||||
setUpscaleScheduler: (state, action: PayloadAction<ParameterScheduler>) => {
|
||||
state.upscaleScheduler = action.payload;
|
||||
},
|
||||
|
||||
setSeed: (state, action: PayloadAction<number>) => {
|
||||
state.seed = action.payload;
|
||||
state.shouldRandomizeSeed = false;
|
||||
@@ -315,6 +326,8 @@ export const {
|
||||
setCfgRescaleMultiplier,
|
||||
setGuidance,
|
||||
setScheduler,
|
||||
setUpscaleScheduler,
|
||||
setUpscaleCfgScale,
|
||||
setSeed,
|
||||
setImg2imgStrength,
|
||||
setOptimizedDenoisingEnabled,
|
||||
@@ -409,6 +422,9 @@ export const selectVAEPrecision = createParamsSelector((params) => params.vaePre
|
||||
export const selectIterations = createParamsSelector((params) => params.iterations);
|
||||
export const selectShouldUseCPUNoise = createParamsSelector((params) => params.shouldUseCpuNoise);
|
||||
|
||||
export const selectUpscaleScheduler = createParamsSelector((params) => params.upscaleScheduler);
|
||||
export const selectUpscaleCfgScale = createParamsSelector((params) => params.upscaleCfgScale);
|
||||
|
||||
export const selectRefinerCFGScale = createParamsSelector((params) => params.refinerCFGScale);
|
||||
export const selectRefinerModel = createParamsSelector((params) => params.refinerModel);
|
||||
export const selectIsRefinerModelSelected = createParamsSelector((params) => Boolean(params.refinerModel));
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { CanvasState } from 'features/controlLayers/store/types';
|
||||
import { selectDeleteImageModalSlice } from 'features/deleteImageModal/store/slice';
|
||||
import { selectNodesSlice } from 'features/nodes/store/selectors';
|
||||
import type { NodesState } from 'features/nodes/store/types';
|
||||
import { isImageFieldInputInstance } from 'features/nodes/types/field';
|
||||
import { isImageFieldCollectionInputInstance, isImageFieldInputInstance } from 'features/nodes/types/field';
|
||||
import { isInvocationNode } from 'features/nodes/types/invocation';
|
||||
import type { UpscaleState } from 'features/parameters/store/upscaleSlice';
|
||||
import { selectUpscaleSlice } from 'features/parameters/store/upscaleSlice';
|
||||
@@ -13,11 +13,23 @@ import { some } from 'lodash-es';
|
||||
import type { ImageUsage } from './types';
|
||||
// TODO(psyche): handle image deletion (canvas staging area?)
|
||||
export const getImageUsage = (nodes: NodesState, canvas: CanvasState, upscale: UpscaleState, image_name: string) => {
|
||||
const isNodesImage = nodes.nodes
|
||||
.filter(isInvocationNode)
|
||||
.some((node) =>
|
||||
some(node.data.inputs, (input) => isImageFieldInputInstance(input) && input.value?.image_name === image_name)
|
||||
);
|
||||
const isNodesImage = nodes.nodes.filter(isInvocationNode).some((node) =>
|
||||
some(node.data.inputs, (input) => {
|
||||
if (isImageFieldInputInstance(input)) {
|
||||
if (input.value?.image_name === image_name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isImageFieldCollectionInputInstance(input)) {
|
||||
if (input.value?.some((value) => value?.image_name === image_name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
);
|
||||
|
||||
const isUpscaleImage = upscale.upscaleInitialImage?.image_name === image_name;
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ import type { DndListTargetState } from 'features/dnd/types';
|
||||
*/
|
||||
const line = {
|
||||
thickness: 2,
|
||||
backgroundColor: 'base.500',
|
||||
backgroundColor: 'red',
|
||||
// backgroundColor: 'base.500',
|
||||
};
|
||||
|
||||
type DropIndicatorProps = {
|
||||
@@ -104,7 +105,7 @@ function DndDropIndicatorInternal({ edge, gap = '0px' }: DropIndicatorProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export const DndListDropIndicator = ({ dndState }: { dndState: DndListTargetState }) => {
|
||||
export const DndListDropIndicator = ({ dndState, gap }: { dndState: DndListTargetState; gap?: string }) => {
|
||||
if (dndState.type !== 'is-dragging-over') {
|
||||
return null;
|
||||
}
|
||||
@@ -117,7 +118,7 @@ export const DndListDropIndicator = ({ dndState }: { dndState: DndListTargetStat
|
||||
<DndDropIndicatorInternal
|
||||
edge={dndState.closestEdge}
|
||||
// This is the gap between items in the list, used to calculate the position of the drop indicator
|
||||
gap="var(--invoke-space-2)"
|
||||
gap={gap || 'var(--invoke-space-2)'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,8 +22,8 @@ import { useBoardName } from 'services/api/hooks/useBoardName';
|
||||
import type { UploadImageArg } from 'services/api/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpg', 'image/jpeg'];
|
||||
const ACCEPTED_FILE_EXTENSIONS = ['.png', '.jpg', '.jpeg'];
|
||||
const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpg', 'image/jpeg', 'image/webp'];
|
||||
const ACCEPTED_FILE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp'];
|
||||
|
||||
// const MAX_IMAGE_SIZE = 4; //In MegaBytes
|
||||
// const sizeInMB = (sizeInBytes: number, decimalsNum = 2) => {
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
setUpscaleInitialImage,
|
||||
} from 'features/imageActions/actions';
|
||||
import { fieldImageCollectionValueChanged } from 'features/nodes/store/nodesSlice';
|
||||
import { selectFieldInputInstance, selectNodesSlice } from 'features/nodes/store/selectors';
|
||||
import { selectFieldInputInstanceSafe, selectNodesSlice } from 'features/nodes/store/selectors';
|
||||
import { type FieldIdentifier, isImageFieldCollectionInputInstance } from 'features/nodes/types/field';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
import type { JsonObject } from 'type-fest';
|
||||
@@ -108,18 +108,6 @@ export const singleCanvasEntityDndSource: DndSource<SingleCanvasEntityDndSourceD
|
||||
getData: buildGetData(_singleCanvasEntity.key, _singleCanvasEntity.type),
|
||||
};
|
||||
|
||||
const _singleWorkflowField = buildTypeAndKey('single-workflow-field');
|
||||
type SingleWorkflowFieldDndSourceData = DndData<
|
||||
typeof _singleWorkflowField.type,
|
||||
typeof _singleWorkflowField.key,
|
||||
{ fieldIdentifier: FieldIdentifier }
|
||||
>;
|
||||
export const singleWorkflowFieldDndSource: DndSource<SingleWorkflowFieldDndSourceData> = {
|
||||
..._singleWorkflowField,
|
||||
typeGuard: buildTypeGuard(_singleWorkflowField.key),
|
||||
getData: buildGetData(_singleWorkflowField.key, _singleWorkflowField.type),
|
||||
};
|
||||
|
||||
type DndTarget<TargetData extends DndData, SourceData extends DndData> = {
|
||||
key: symbol;
|
||||
type: TargetData['type'];
|
||||
@@ -273,7 +261,7 @@ export const addImagesToNodeImageFieldCollectionDndTarget: DndTarget<
|
||||
|
||||
const { fieldIdentifier } = targetData.payload;
|
||||
|
||||
const fieldInputInstance = selectFieldInputInstance(
|
||||
const fieldInputInstance = selectFieldInputInstanceSafe(
|
||||
selectNodesSlice(getState()),
|
||||
fieldIdentifier.nodeId,
|
||||
fieldIdentifier.fieldName
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Flex, IconButton, Input, Text } from '@invoke-ai/ui-library';
|
||||
import { useBoolean } from 'common/hooks/useBoolean';
|
||||
import { useEditable } from 'common/hooks/useEditable';
|
||||
import { withResultAsync } from 'common/util/result';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiPencilBold } from 'react-icons/pi';
|
||||
import { useUpdateBoardMutation } from 'services/api/endpoints/boards';
|
||||
@@ -16,85 +16,54 @@ type Props = {
|
||||
|
||||
export const BoardEditableTitle = memo(({ board, isSelected }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const isEditing = useBoolean(false);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [localTitle, setLocalTitle] = useState(board.board_name);
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const isHovering = useBoolean(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [updateBoard, updateBoardResult] = useUpdateBoardMutation();
|
||||
|
||||
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalTitle(e.target.value);
|
||||
}, []);
|
||||
|
||||
const onEdit = useCallback(() => {
|
||||
isEditing.setTrue();
|
||||
setIsHovering(false);
|
||||
}, [isEditing]);
|
||||
|
||||
const onBlur = useCallback(async () => {
|
||||
const trimmedTitle = localTitle.trim();
|
||||
isEditing.setFalse();
|
||||
if (trimmedTitle.length === 0) {
|
||||
setLocalTitle(board.board_name);
|
||||
} else if (trimmedTitle !== board.board_name) {
|
||||
setLocalTitle(trimmedTitle);
|
||||
const onChange = useCallback(
|
||||
async (board_name: string) => {
|
||||
const result = await withResultAsync(() =>
|
||||
updateBoard({ board_id: board.board_id, changes: { board_name: trimmedTitle } }).unwrap()
|
||||
updateBoard({ board_id: board.board_id, changes: { board_name } }).unwrap()
|
||||
);
|
||||
if (result.isErr()) {
|
||||
setLocalTitle(board.board_name);
|
||||
toast({
|
||||
status: 'error',
|
||||
title: t('boards.updateBoardError'),
|
||||
});
|
||||
} else {
|
||||
setLocalTitle(result.value.board_name);
|
||||
}
|
||||
}
|
||||
}, [board.board_id, board.board_name, isEditing, localTitle, updateBoard, t]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
onBlur();
|
||||
} else if (e.key === 'Escape') {
|
||||
setLocalTitle(board.board_name);
|
||||
isEditing.setFalse();
|
||||
}
|
||||
},
|
||||
[board.board_name, isEditing, onBlur]
|
||||
[board.board_id, t, updateBoard]
|
||||
);
|
||||
|
||||
const handleMouseOver = useCallback(() => {
|
||||
setIsHovering(true);
|
||||
}, []);
|
||||
const editable = useEditable({
|
||||
value: board.board_name,
|
||||
defaultValue: board.board_name,
|
||||
onChange,
|
||||
inputRef,
|
||||
onStartEditing: isHovering.setTrue,
|
||||
});
|
||||
|
||||
const handleMouseOut = useCallback(() => {
|
||||
setIsHovering(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing.isTrue) {
|
||||
ref.current?.focus();
|
||||
ref.current?.select();
|
||||
}
|
||||
}, [isEditing.isTrue]);
|
||||
|
||||
if (!isEditing.isTrue) {
|
||||
if (!editable.isEditing) {
|
||||
return (
|
||||
<Flex alignItems="center" gap={3} onMouseOver={handleMouseOver} onMouseOut={handleMouseOut}>
|
||||
<Flex alignItems="center" gap={3} onMouseOver={isHovering.setTrue} onMouseOut={isHovering.setFalse}>
|
||||
<Text
|
||||
size="sm"
|
||||
fontWeight="semibold"
|
||||
userSelect="none"
|
||||
color={isSelected ? 'base.100' : 'base.300'}
|
||||
onDoubleClick={onEdit}
|
||||
onDoubleClick={editable.startEditing}
|
||||
cursor="text"
|
||||
>
|
||||
{localTitle}
|
||||
{editable.value}
|
||||
</Text>
|
||||
{isHovering && (
|
||||
<IconButton aria-label="edit name" icon={<PiPencilBold />} size="sm" variant="ghost" onClick={onEdit} />
|
||||
{isHovering.isTrue && (
|
||||
<IconButton
|
||||
aria-label="edit name"
|
||||
icon={<PiPencilBold />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={editable.startEditing}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
@@ -102,11 +71,8 @@ export const BoardEditableTitle = memo(({ board, isSelected }: Props) => {
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
value={localTitle}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
ref={inputRef}
|
||||
{...editable.inputProps}
|
||||
variant="outline"
|
||||
isDisabled={updateBoardResult.isLoading}
|
||||
_focusVisible={{ borderWidth: 1, borderColor: 'invokeBlueAlpha.400', borderRadius: 'base' }}
|
||||
|
||||
@@ -36,7 +36,13 @@ const DeleteBoardModal = () => {
|
||||
const boardToDelete = useStore($boardToDelete);
|
||||
const { t } = useTranslation();
|
||||
const { currentData: boardImageNames, isFetching: isFetchingBoardNames } = useListAllImageNamesForBoardQuery(
|
||||
boardToDelete?.board_id ?? skipToken
|
||||
boardToDelete?.board_id
|
||||
? {
|
||||
board_id: boardToDelete?.board_id,
|
||||
categories: undefined,
|
||||
is_intermediate: undefined,
|
||||
}
|
||||
: skipToken
|
||||
);
|
||||
|
||||
const selectImageUsageSummary = useMemo(
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useFocusRegion } from 'common/hooks/focus';
|
||||
import { GalleryHeader } from 'features/gallery/components/GalleryHeader';
|
||||
import { selectBoardSearchText } from 'features/gallery/store/gallerySelectors';
|
||||
import { boardSearchTextChanged } from 'features/gallery/store/gallerySlice';
|
||||
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
|
||||
import { HorizontalResizeHandle } from 'features/ui/components/tabs/ResizeHandle';
|
||||
import { usePanel, type UsePanelOptions } from 'features/ui/hooks/usePanel';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
@@ -94,7 +94,7 @@ const GalleryPanelContent = () => {
|
||||
<BoardsListWrapper />
|
||||
</Flex>
|
||||
</Panel>
|
||||
<ResizeHandle id="gallery-panel-handle" {...boardsListPanel.resizeHandleProps} />
|
||||
<HorizontalResizeHandle id="gallery-panel-handle" {...boardsListPanel.resizeHandleProps} />
|
||||
<Panel id="gallery-wrapper-panel" minSize={20}>
|
||||
<Gallery />
|
||||
</Panel>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { IconMenuItem } from 'common/components/IconMenuItem';
|
||||
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
|
||||
import { imageOpenedInNewTab } from 'features/gallery/store/actions';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiArrowSquareOutBold } from 'react-icons/pi';
|
||||
@@ -7,9 +9,11 @@ import { PiArrowSquareOutBold } from 'react-icons/pi';
|
||||
export const ImageMenuItemOpenInNewTab = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const imageDTO = useImageDTOContext();
|
||||
const dispatch = useAppDispatch();
|
||||
const onClick = useCallback(() => {
|
||||
window.open(imageDTO.image_url, '_blank');
|
||||
}, [imageDTO.image_url]);
|
||||
dispatch(imageOpenedInNewTab());
|
||||
}, [imageDTO.image_url, dispatch]);
|
||||
|
||||
return (
|
||||
<IconMenuItem
|
||||
|
||||
@@ -6,7 +6,6 @@ import { createSelector } from '@reduxjs/toolkit';
|
||||
import { galleryImageClicked } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useBoolean } from 'common/hooks/useBoolean';
|
||||
import { multipleImageDndSource, singleImageDndSource } from 'features/dnd/dnd';
|
||||
import type { DndDragPreviewMultipleImageState } from 'features/dnd/DndDragPreviewMultipleImage';
|
||||
import { createMultipleImageDragPreview, setMultipleImageDragPreview } from 'features/dnd/DndDragPreviewMultipleImage';
|
||||
@@ -178,7 +177,15 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
|
||||
);
|
||||
}, [imageDTO, element, store, dndId]);
|
||||
|
||||
const isHovered = useBoolean(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const onMouseOver = useCallback(() => {
|
||||
setIsHovered(true);
|
||||
}, []);
|
||||
|
||||
const onMouseOut = useCallback(() => {
|
||||
setIsHovered(false);
|
||||
}, []);
|
||||
|
||||
const onClick = useCallback<MouseEventHandler<HTMLDivElement>>(
|
||||
(e) => {
|
||||
@@ -217,8 +224,8 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
|
||||
<Flex
|
||||
role="button"
|
||||
className="gallery-image"
|
||||
onMouseOver={isHovered.setTrue}
|
||||
onMouseOut={isHovered.setFalse}
|
||||
onMouseOver={onMouseOver}
|
||||
onMouseOut={onMouseOut}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
data-selected={isSelected}
|
||||
@@ -234,7 +241,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
|
||||
maxH="full"
|
||||
borderRadius="base"
|
||||
/>
|
||||
<GalleryImageHoverIcons imageDTO={imageDTO} isHovered={isHovered.isTrue} />
|
||||
<GalleryImageHoverIcons imageDTO={imageDTO} isHovered={isHovered} />
|
||||
</Flex>
|
||||
</Box>
|
||||
{dragPreviewState?.type === 'multiple-image' ? createMultipleImageDragPreview(dragPreviewState) : null}
|
||||
|
||||
@@ -17,79 +17,19 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const JumpTo = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const { goToPage, currentPage, pages } = useGalleryPagination();
|
||||
const [newPage, setNewPage] = useState(currentPage);
|
||||
const { isOpen, onToggle, onClose } = useDisclosure();
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onOpen = useCallback(() => {
|
||||
setNewPage(currentPage);
|
||||
setTimeout(() => {
|
||||
const input = ref.current?.querySelector('input');
|
||||
input?.focus();
|
||||
input?.select();
|
||||
}, 0);
|
||||
}, [currentPage]);
|
||||
|
||||
const onChangeJumpTo = useCallback((v: number) => {
|
||||
setNewPage(v - 1);
|
||||
}, []);
|
||||
|
||||
const onClickGo = useCallback(() => {
|
||||
goToPage(newPage);
|
||||
onClose();
|
||||
}, [newPage, goToPage, onClose]);
|
||||
|
||||
useHotkeys(
|
||||
'enter',
|
||||
() => {
|
||||
onClickGo();
|
||||
},
|
||||
{ enabled: isOpen, enableOnFormTags: ['input'] },
|
||||
[isOpen, onClickGo]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'esc',
|
||||
() => {
|
||||
setNewPage(currentPage);
|
||||
onClose();
|
||||
},
|
||||
{ enabled: isOpen, enableOnFormTags: ['input'] },
|
||||
[isOpen, onClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setNewPage(currentPage);
|
||||
}, [currentPage]);
|
||||
const disclosure = useDisclosure();
|
||||
|
||||
return (
|
||||
<Popover isOpen={isOpen} onClose={onClose} onOpen={onOpen} isLazy lazyBehavior="unmount">
|
||||
<Popover isOpen={disclosure.isOpen} onClose={disclosure.onClose} isLazy lazyBehavior="unmount">
|
||||
<PopoverTrigger>
|
||||
<Button aria-label={t('gallery.jump')} size="sm" onClick={onToggle} variant="outline">
|
||||
<Button aria-label={t('gallery.jump')} size="sm" onClick={disclosure.onToggle} variant="outline">
|
||||
{t('gallery.jump')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverArrow />
|
||||
<PopoverBody>
|
||||
<Flex gap={2} alignItems="center">
|
||||
<FormControl>
|
||||
<CompositeNumberInput
|
||||
ref={ref}
|
||||
size="sm"
|
||||
maxW="60px"
|
||||
value={newPage + 1}
|
||||
min={1}
|
||||
max={pages}
|
||||
step={1}
|
||||
onChange={onChangeJumpTo}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button h="full" size="sm" onClick={onClickGo}>
|
||||
{t('gallery.go')}
|
||||
</Button>
|
||||
</Flex>
|
||||
<JumpToContent disclosure={disclosure} />
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -97,3 +37,68 @@ export const JumpTo = memo(() => {
|
||||
});
|
||||
|
||||
JumpTo.displayName = 'JumpTo';
|
||||
|
||||
const JumpToContent = memo(({ disclosure }: { disclosure: ReturnType<typeof useDisclosure> }) => {
|
||||
const { t } = useTranslation();
|
||||
const { goToPage, currentPage, pages } = useGalleryPagination();
|
||||
const [newPage, setNewPage] = useState(currentPage);
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onChangeJumpTo = useCallback((v: number) => {
|
||||
setNewPage(v - 1);
|
||||
}, []);
|
||||
|
||||
const onClickGo = useCallback(() => {
|
||||
goToPage(newPage);
|
||||
disclosure.onClose();
|
||||
}, [goToPage, newPage, disclosure]);
|
||||
|
||||
useHotkeys(
|
||||
'enter',
|
||||
() => {
|
||||
onClickGo();
|
||||
},
|
||||
{ enabled: disclosure.isOpen, enableOnFormTags: ['input'] },
|
||||
[disclosure.isOpen, onClickGo]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'esc',
|
||||
() => {
|
||||
setNewPage(currentPage);
|
||||
disclosure.onClose();
|
||||
},
|
||||
{ enabled: disclosure.isOpen, enableOnFormTags: ['input'] },
|
||||
[disclosure.isOpen, disclosure.onClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
const input = ref.current?.querySelector('input');
|
||||
input?.focus();
|
||||
input?.select();
|
||||
}, 0);
|
||||
setNewPage(currentPage);
|
||||
}, [currentPage]);
|
||||
|
||||
return (
|
||||
<Flex gap={2} alignItems="center">
|
||||
<FormControl>
|
||||
<CompositeNumberInput
|
||||
ref={ref}
|
||||
size="sm"
|
||||
maxW="60px"
|
||||
value={newPage + 1}
|
||||
min={1}
|
||||
max={pages}
|
||||
step={1}
|
||||
onChange={onChangeJumpTo}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button h="full" size="sm" onClick={onClickGo}>
|
||||
{t('gallery.go')}
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
JumpToContent.displayName = 'JumpToContent';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { FlexProps } from '@invoke-ai/ui-library';
|
||||
import { Box, Flex, IconButton, Tooltip, useShiftModifier } from '@invoke-ai/ui-library';
|
||||
import { getOverlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants';
|
||||
import { useClipboard } from 'common/hooks/useClipboard';
|
||||
@@ -18,12 +19,12 @@ type Props = {
|
||||
withDownload?: boolean;
|
||||
withCopy?: boolean;
|
||||
extraCopyActions?: { label: string; getData: (data: unknown) => unknown }[];
|
||||
};
|
||||
} & FlexProps;
|
||||
|
||||
const overlayscrollbarsOptions = getOverlayScrollbarsParams('scroll', 'scroll').options;
|
||||
|
||||
const DataViewer = (props: Props) => {
|
||||
const { label, data, fileName, withDownload = true, withCopy = true, extraCopyActions } = props;
|
||||
const { label, data, fileName, withDownload = true, withCopy = true, extraCopyActions, ...rest } = props;
|
||||
const dataString = useMemo(() => (isString(data) ? data : formatter.Serialize(data)) ?? '', [data]);
|
||||
const shift = useShiftModifier();
|
||||
const clipboard = useClipboard();
|
||||
@@ -44,8 +45,8 @@ const DataViewer = (props: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Flex layerStyle="second" borderRadius="base" flexGrow={1} w="full" h="full" position="relative">
|
||||
<Box position="absolute" top={0} left={0} right={0} bottom={0} overflow="auto" p={4} fontSize="sm">
|
||||
<Flex bg="base.800" borderRadius="base" flexGrow={1} w="full" h="full" position="relative" {...rest}>
|
||||
<Box position="absolute" top={0} left={0} right={0} bottom={0} overflow="auto" p={2} fontSize="sm">
|
||||
<OverlayScrollbarsComponent defer style={overlayScrollbarsStyles} options={overlayscrollbarsOptions}>
|
||||
<pre>{dataString}</pre>
|
||||
</OverlayScrollbarsComponent>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user